Repository: nikivdev/flow Branch: main Commit: 6a108e90229f Files: 400 Total size: 5.1 MB Directory structure: gitextract_aj30rg7u/ ├── .ai/ │ ├── agents.md │ ├── docs/ │ │ ├── .last_sync │ │ ├── architecture.md │ │ ├── changelog.md │ │ └── commands.md │ ├── flox/ │ │ └── manifest.toml │ ├── health-checks.json │ ├── recipes/ │ │ └── project/ │ │ ├── bootstrap-core-tools.md │ │ ├── install-rise-auto.md │ │ ├── installer-smoke-sh.md │ │ ├── release-core-toolchain.md │ │ ├── release-flow-registry.md │ │ ├── release-rise-registry.md │ │ └── release-seq-registry.md │ ├── repos.toml │ ├── tasks/ │ │ └── flow/ │ │ ├── bench-cli/ │ │ │ ├── main.mbt │ │ │ ├── moon.mod.json │ │ │ └── moon.pkg.json │ │ ├── dev-check/ │ │ │ ├── main.mbt │ │ │ ├── moon.mod.json │ │ │ └── moon.pkg.json │ │ ├── noop/ │ │ │ ├── main.mbt │ │ │ ├── moon.mod.json │ │ │ └── moon.pkg.json │ │ ├── pr-ready/ │ │ │ ├── main.mbt │ │ │ ├── moon.mod.json │ │ │ └── moon.pkg.json │ │ ├── regression-smoke/ │ │ │ ├── main.mbt │ │ │ ├── moon.mod.json │ │ │ └── moon.pkg.json │ │ └── release-preflight/ │ │ ├── main.mbt │ │ ├── moon.mod.json │ │ └── moon.pkg.json │ └── todos/ │ └── todos.json ├── .flox.disabled ├── .github/ │ └── workflows/ │ ├── canary.yml │ ├── nightly-validation.yml │ ├── pr-fast.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .pi/ │ └── extensions/ │ └── test-extensibility.ts ├── AGENTS.md.bak ├── Cargo.toml ├── Support/ │ └── flow/ │ └── auth.toml ├── agents.md ├── bench/ │ ├── ffi_host_boundary/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── bin/ │ │ │ └── rust_boundary_bench.rs │ │ └── lib.rs │ └── moon_ffi_boundary/ │ ├── main.mbt │ ├── moon.mod.json │ └── moon.pkg.template.json ├── build.rs ├── crates/ │ ├── ai_taskd_client/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── main.rs │ ├── flow_commit_scan/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── opentui-lite/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── seq_client/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ └── seq_everruns_bridge/ │ ├── Cargo.toml │ └── src/ │ ├── lib.rs │ └── maple.rs ├── docs/ │ ├── ai-dev-layout.md │ ├── ai-run-task-fast-path.md │ ├── ai-task-fast-path-guide.md │ ├── ai-traces.md │ ├── anonymous-telemetry-contract.md │ ├── ascii-commit-visualization-pipeline.md │ ├── bench/ │ │ ├── cli-startup.md │ │ ├── moonbit-rust-ffi-boundary.md │ │ └── readme.md │ ├── ci-cd-runbook.md │ ├── codex-first-control-plane-roadmap.md │ ├── codex-fork-tasks.md │ ├── codex-maple-telemetry-runbook.md │ ├── codex-openai-session-resolver.md │ ├── commands/ │ │ ├── ai.md │ │ ├── clone.md │ │ ├── commit.md │ │ ├── commits.md │ │ ├── db.md │ │ ├── deploy.md │ │ ├── docs.md │ │ ├── domains.md │ │ ├── down.md │ │ ├── env.md │ │ ├── fast.md │ │ ├── global.md │ │ ├── install.md │ │ ├── invariants.md │ │ ├── jj.md │ │ ├── migrate.md │ │ ├── new.md │ │ ├── pr.md │ │ ├── publish.md │ │ ├── readme.md │ │ ├── recipe.md │ │ ├── release.md │ │ ├── repos.md │ │ ├── reviews-todo.md │ │ ├── seq-rpc.md │ │ ├── services.md │ │ ├── setup.md │ │ ├── skills.md │ │ ├── sync.md │ │ ├── tasks.md │ │ ├── up.md │ │ ├── upstream.md │ │ ├── url.md │ │ └── web.md │ ├── commits/ │ │ ├── .gitkeep │ │ └── readme.md │ ├── dependency-vendoring.md │ ├── dev-server-management.md │ ├── env-security-roadmap.md │ ├── everruns-maple-runbook.md │ ├── everruns-seq-bridge-integration.md │ ├── fast-commit-deep-review-loop.md │ ├── features.md │ ├── flow-toml-spec.md │ ├── how-api-expects-logs-errors-for-automatic-fixes.md │ ├── how-flow-daemon-manages-macos-services.md │ ├── how-to-make-a-project-flow-project.md │ ├── how-to-use-flow-ai-to-manage-claude-code-sessions.md │ ├── how-to-use-flow-to-deploy.md │ ├── how-to-use-flow-to-store-and-work-with-env.md │ ├── ideas.toml │ ├── index.mdx │ ├── install-script-latest-release-verification.md │ ├── jj-home-branch-workflow.md │ ├── jj-review-workspaces.md │ ├── jj-workspaces-for-parallel-work.md │ ├── local-domains-domainsd-cpp-spec.md │ ├── local-domains-no-random-ports.md │ ├── log-ingesting.md │ ├── moonbit-ai-tasks-implementation.md │ ├── moonbit-rust-boundary-refactor-plan.md │ ├── moving-repos.md │ ├── myflow-localhost-runbook.md │ ├── new-branch.md │ ├── new-pr.md │ ├── outdated-readme.md │ ├── pr-edit-watcher.md │ ├── private-fork-flow.md │ ├── private-mirror-sync-workflow.md │ ├── private-repo-fast.md │ ├── proxyx-design.md │ ├── read-stream-of-logs.md │ ├── rise-sandbox-feature-test-runbook.md │ ├── rise.md │ ├── rl-for-myflow-harbor.md │ ├── rl-myflow-harbor-task-specs.md │ ├── rl-signal-capture-runbook.md │ ├── run-repos.md │ ├── seq-agent-rpc-contract.md │ ├── session-history-mining.md │ ├── session-semantic-recovery-with-seq.md │ ├── set-env-with-hive.md │ ├── task-failure-hooks.md │ ├── usage-analytics-rollout.md │ ├── use-flow-to-write-software-better.md │ ├── vendor-code-intelligence.md │ ├── vendor-nix-inspiration.md │ └── vendor-optimization-loop.md ├── flow.py ├── flow.toml ├── install.sh ├── lib/ │ └── vendor-manifest/ │ ├── axum.toml │ ├── clap.toml │ ├── crossterm.toml │ ├── crypto_secretbox.toml │ ├── ctrlc.toml │ ├── futures.toml │ ├── hmac.toml │ ├── ignore.toml │ ├── notify-debouncer-mini.toml │ ├── notify.toml │ ├── portable-pty.toml │ ├── ratatui.toml │ ├── regex.toml │ ├── reqwest.toml │ ├── rmp-serde.toml │ ├── rusqlite.toml │ ├── serde.toml │ ├── sha1.toml │ ├── sha2.toml │ ├── tokio-stream.toml │ ├── tokio.toml │ ├── toml.toml │ ├── tower-http.toml │ ├── tracing-subscriber.toml │ ├── url.toml │ └── x25519-dalek.toml ├── license ├── pyproject.toml ├── readme.md ├── scripts/ │ ├── agents-switch.sh │ ├── ai-taskd-launchd.py │ ├── bench-ai-runtime.py │ ├── bench-cli-startup.py │ ├── bench-moonbit-rust-ffi.py │ ├── build_rl_runtime_dataset.py │ ├── cdn-nginx.conf │ ├── check_cli_startup_thresholds.py │ ├── check_release_tag_version.py │ ├── ci/ │ │ └── check-readme-case.sh │ ├── ci_blacksmith.py │ ├── ci_host_runner.py │ ├── ci_host_setup.sh │ ├── cli_startup_thresholds.json │ ├── codex-flow-wrapper │ ├── codex-jazz-wrapper │ ├── codex-skill-eval-launchd.py │ ├── codex_fork.py │ ├── deploy.sh │ ├── deps_check.py │ ├── generate_help_full_json.py │ ├── install-linux-hub.sh │ ├── install-macos-dev.sh │ ├── install.sh │ ├── myflow-commit-session-smoke.sh │ ├── package-release.sh │ ├── pre-push-guard.sh │ ├── publish-release.sh │ ├── release.sh │ ├── remote-hub-setup.sh │ ├── rl_signal_summary.py │ ├── run-health-checks.mjs │ ├── run-repos.sh │ ├── setup-github-ssh.sh │ ├── setup-release-host.sh │ ├── sync-cdn.sh │ ├── vendor/ │ │ ├── apply-trims.sh │ │ ├── bench_iteration.py │ │ ├── check-upstream.sh │ │ ├── important-crates.txt │ │ ├── inhouse-crate.sh │ │ ├── materialize-all.sh │ │ ├── offenders.sh │ │ ├── optimize_loop.sh │ │ ├── rough_edges_audit.py │ │ ├── sync-all.sh │ │ ├── sync-crate.sh │ │ ├── trim-hooks.sh │ │ ├── typesense_code_index.py │ │ ├── update-deps.sh │ │ └── vendor-repo.sh │ └── verify-install-latest-release.sh ├── spec/ │ └── tracing-flow.md ├── src/ │ ├── activity_log.rs │ ├── agent_setup.rs │ ├── agents.rs │ ├── ai.rs │ ├── ai_context.rs │ ├── ai_everruns.rs │ ├── ai_server.rs │ ├── ai_taskd.rs │ ├── ai_tasks.rs │ ├── ai_test.rs │ ├── analytics.rs │ ├── archive.rs │ ├── ask.rs │ ├── auth.rs │ ├── base_tool.rs │ ├── bin/ │ │ ├── ai_taskd_client.rs │ │ └── lin.rs │ ├── branches.rs │ ├── changes.rs │ ├── cli.rs │ ├── code.rs │ ├── codex_memory.rs │ ├── codex_runtime.rs │ ├── codex_skill_eval.rs │ ├── codex_telemetry.rs │ ├── codex_text.rs │ ├── codexd.rs │ ├── commit.rs │ ├── commits.rs │ ├── config.rs │ ├── daemon.rs │ ├── daemon_snapshot.rs │ ├── db.rs │ ├── deploy.rs │ ├── deploy_setup.rs │ ├── deps.rs │ ├── discover.rs │ ├── docs.rs │ ├── doctor.rs │ ├── domains.rs │ ├── env.rs │ ├── env_setup.rs │ ├── explain_commits.rs │ ├── ext.rs │ ├── features.rs │ ├── fish_install.rs │ ├── fish_trace.rs │ ├── fix.rs │ ├── fixup.rs │ ├── flox.rs │ ├── gh_release.rs │ ├── git_guard.rs │ ├── gitignore_policy.rs │ ├── hash.rs │ ├── health.rs │ ├── help_full.json │ ├── help_search.rs │ ├── history.rs │ ├── hive.rs │ ├── home.rs │ ├── http_client.rs │ ├── hub.rs │ ├── indexer.rs │ ├── info.rs │ ├── init.rs │ ├── install.rs │ ├── invariants.rs │ ├── jazz_state.rs │ ├── jazz_state_stub.rs │ ├── jj.rs │ ├── json_parse.rs │ ├── latest.rs │ ├── lib.rs │ ├── lifecycle.rs │ ├── lmstudio.rs │ ├── log_server.rs │ ├── log_store.rs │ ├── logs.rs │ ├── macos.rs │ ├── main.rs │ ├── notify.rs │ ├── opentui_prompt.rs │ ├── otp.rs │ ├── palette.rs │ ├── parallel.rs │ ├── path_hygiene.rs │ ├── pr_edit.rs │ ├── processes.rs │ ├── project_snapshot.rs │ ├── projects.rs │ ├── proxy/ │ │ ├── mod.rs │ │ ├── server.rs │ │ ├── summary.rs │ │ └── trace.rs │ ├── publish.rs │ ├── push.rs │ ├── recipe.rs │ ├── registry.rs │ ├── release.rs │ ├── release_signing.rs │ ├── repo_capsule.rs │ ├── repos.rs │ ├── reviews_todo.rs │ ├── rl_signals.rs │ ├── running.rs │ ├── screen.rs │ ├── sealer_crypto.rs │ ├── secret_redact.rs │ ├── secrets.rs │ ├── seq_client.rs │ ├── seq_rpc.rs │ ├── server.rs │ ├── servers.rs │ ├── servers_tui.rs │ ├── services.rs │ ├── setup.rs │ ├── skills.rs │ ├── ssh.rs │ ├── ssh_keys.rs │ ├── start.rs │ ├── storage.rs │ ├── supervisor.rs │ ├── sync.rs │ ├── task_failure_agents.rs │ ├── task_match.rs │ ├── tasks.rs │ ├── terminal.rs │ ├── todo.rs │ ├── tools.rs │ ├── trace.rs │ ├── traces.rs │ ├── traces_stub.rs │ ├── undo.rs │ ├── upgrade.rs │ ├── upstream.rs │ ├── url_inspect.rs │ ├── usage.rs │ ├── vcs.rs │ ├── watchers.rs │ ├── web.rs │ └── workflow.rs ├── test-extension.md ├── tests/ │ ├── deps.ts │ └── test_log_server.ts ├── tools/ │ └── domainsd-cpp/ │ ├── domainsd.cpp │ ├── install-macos-launchd.sh │ ├── readme.md │ └── uninstall-macos-launchd.sh └── vendor.lock.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .ai/agents.md ================================================ # Autonomous Agent Instructions Project: flow This project is configured for autonomous AI agent workflows with human-in-the-loop approval. ## Response Format **Every response MUST end with exactly one of these signals on the final line:** ### Success signals ``` done. ``` Use when task completed successfully with high certainty. No further action needed. ``` done: ``` Use when task completed with context to share. Example: `done: Added login command with --token flag` ### Needs human input ``` needsUpdate: ``` Use when you need human decision or action. Example: `needsUpdate: Should I use OAuth or API key auth?` ### Error signals ``` error: ``` Use when task failed or cannot proceed. Example: `error: Build failed - missing dependency xyz` ## Rules 1. **Always end with a signal** - The last line must be one of the above 2. **One signal only** - Never combine signals 3. **Be specific** - Include actionable context in messages 4. **No quotes** - Write signals exactly as shown, no wrapping quotes ## Examples ### Successful implementation ``` Added the new CLI command with all requested flags. done. ``` ### Completed with context ``` Refactored the auth module to use the new token format. done: Auth now supports both JWT and API key methods ``` ### Need human decision ``` Found two approaches for caching: 1. Redis - better for distributed systems 2. In-memory - simpler, faster for single instance needsUpdate: Which caching approach should I use? ``` ### Error occurred ``` Attempted to run tests but encountered issues. error: Test suite requires DATABASE_URL environment variable ``` ================================================ FILE: .ai/docs/.last_sync ================================================ 2026-01-01 03:08:07 (4e4a4e7) ================================================ FILE: .ai/docs/architecture.md ================================================ # Flow Architecture ## Overview Flow is a CLI tool and task runner written in Rust. It provides project automation, AI-assisted development workflows, and deployment capabilities. ## Project Structure ``` src/ ├── main.rs # CLI entry point, command routing ├── lib.rs # Module exports ├── cli.rs # Clap command definitions ├── config.rs # Configuration loading (flow.toml, global config) ├── tasks.rs # Task execution and discovery ├── parallel.rs # Parallel task runner with TUI ├── commit.rs # AI-powered git commits and code review ├── ai.rs # AI session management (Claude, Codex) ├── deploy.rs # Deployment to hosts/platforms ├── deploy_setup.rs # Interactive deployment setup ├── docs.rs # Auto-generated documentation management ├── daemon.rs # Background daemon management ├── start.rs # Project bootstrap (.ai/ folder) ├── env.rs # Environment variable management ├── skills.rs # Codex skill management ├── tools.rs # AI tool management ├── agent.rs # Kode subagent invocation ├── upstream.rs # Upstream fork workflow ├── hub.rs # Hub daemon management ├── processes.rs # Process tracking and management ├── projects.rs # Project registry ├── history.rs # Task history ├── palette.rs # Fuzzy finder UI ├── notify.rs # Lin notification integration ├── commits.rs # Git commit browser ├── fixup.rs # TOML auto-fix ├── doctor.rs # System diagnostics ├── init.rs # Project scaffolding ├── discover.rs # Config discovery ├── flox.rs # Flox integration ├── task_match.rs # NL task matching via LM Studio ├── lmstudio.rs # LM Studio API client ├── log_server.rs # HTTP log server ├── log_store.rs # Log storage ├── watchers.rs # File watchers ├── running.rs # Running state tracking ├── sync.rs # Sync utilities └── db.rs # Database utilities ``` ## Configuration ### Project Config (`flow.toml`) ```toml name = "project-name" [tasks.dev] run = "cargo run" description = "Run development server" [tasks.build] run = "cargo build --release" [daemons.api] run = "cargo run --bin api" port = 8080 [host] connection = "user@host" domain = "example.com" [cloudflare] name = "worker-name" [commit] review_instructions = "Focus on security" ``` ### Global Config (`~/.config/flow/flow.toml`) Global tasks and settings that apply across all projects. ## Key Concepts ### Tasks Commands defined in `flow.toml` that can be run via `f `. Support descriptions, dependencies, and arguments. ### Daemons Long-running background processes managed by flow. Can be started, stopped, and monitored. ### AI Sessions Integration with Claude Code and Codex. Sessions are tracked in `.ai/internal/sessions/` with checkpoints for context management. ### Deployment Support for: - **Host**: SSH-based deployment to Linux servers - **Cloudflare**: Workers deployment - **Railway**: Platform deployment ### Hub Central daemon for managing servers and aggregating logs across projects. ## Data Flow ### Commit Flow 1. Stage changes (`git add`) 2. Generate diff 3. (Optional) Code review via Codex/Claude 4. Generate commit message via OpenAI 5. Commit and push 6. (Optional) Sync to gitedit.dev ### Task Execution 1. Load `flow.toml` (project or global) 2. Resolve task by name 3. Execute command with environment 4. Capture output and status 5. Store in history ### Parallel Execution 1. Parse task specifications 2. Create async task handles 3. Run with semaphore-based concurrency limit 4. Render real-time TUI with spinners 5. Collect results and display failures ## File Locations ### Per-Project - `.ai/` - AI configuration and data - `actions/` - Fixer scripts - `skills/` - Codex skills - `tools/` - TypeScript tools - `flox/` - Flox manifest - `docs/` - Auto-generated docs - `agents.md` - Agent instructions - `internal/` - Private data (gitignored) - `sessions/` - AI sessions - `checkpoints/` - Checkpoints - `db/` - SQLite database - `.claude/` - Symlinks to `.ai/` (gitignored) - `.codex/` - Symlinks to `.ai/` (gitignored) - `.flox/` - Symlinks to `.ai/flox/` (gitignored) - `flow.toml` - Project configuration ### Global - `~/.config/flow/flow.toml` - Global config - `~/.config/flow/config.toml` - Flow settings - `~/.local/share/flow/` - Data storage - `history.sqlite` - Task history - `projects.json` - Project registry ## Dependencies Key crates: - `clap` - CLI parsing - `tokio` - Async runtime - `axum` - HTTP server - `ratatui` - TUI components - `crossterm` - Terminal manipulation - `reqwest` - HTTP client - `rusqlite` - SQLite - `serde` - Serialization - `toml` - Config parsing ================================================ FILE: .ai/docs/changelog.md ================================================ # Changelog Auto-maintained changelog tracking flow features and changes. ## 2024-12-31 ### Added - **Documentation system** (`f docs`): Auto-generated documentation in `.ai/docs/`. Commands: `list`, `status`, `sync`, `edit`. Docs are updated by AI as part of commit flow. - **Parallel task runner** (`f parallel`): Run multiple tasks concurrently with animated spinners, real-time status display, and pretty output. Supports custom labels (`label:command`) and fail-fast mode. - **Docs update reminder**: Commit flow now detects when docs may need updating and shows a reminder. ### Changed - **`f commit` is now the default**: Full commit with Claude review + GitEdit sync. Just run `f commit` or `f commit -m "note"`. - **Claude is default reviewer**: Use `--codex` to switch to Codex. - **No context by default**: Use `--context` to include AI session context. - **Tokens default 1000**: Use `-t` to change. - **Hidden legacy commands**: `f commit-simple` (no review) and `f commit-with-check` (no gitedit) are hidden but still available. - **`.flox/` materialization**: Flox environment now gitignored and materialized from `.ai/flox/manifest.toml` via `f start`. Source of truth moved to `.ai/flox/`. - **Gitignore section**: `f start` now adds `.flox/` to the `# flow` gitignore section. ## 2024-12-30 ### Added - **Deploy health check** (`f deploy health`): HTTP health check for deployments with configurable URL and expected status code. - **GitEdit review sync**: `f commitWithCheckWithGitedit` now syncs diff, review results, and AI session data to gitedit.dev. ### Changed - **`.ai/` folder restructure**: Separated public (tracked) and private (gitignored) content: - `.ai/actions/`, `.ai/skills/`, `.ai/tools/`, `.ai/flox/` - tracked - `.ai/internal/` - gitignored (sessions, checkpoints, db) - **Tool folder materialization**: `.claude/` and `.codex/` now gitignored and materialized from `.ai/` via symlinks in `f start`. - **Review instructions**: Now auto-discovered from `.ai/review.md`, `.ai/commit-review.md`, or `.ai/instructions.md`. - **Fixers**: Pre-commit fixers now check first before processing, support scripts in `.ai/actions/`. --- ## Document Sync This changelog is updated when commits add new features or make significant changes. To update: 1. Run `f docs sync` (when implemented) 2. Or manually add entries following the format above ### Tracking Commits Recent commits that may need documentation: - Check `git log --oneline -20` for recent changes - Focus on user-facing features and behavior changes ================================================ FILE: .ai/docs/commands.md ================================================ # Flow CLI Commands Auto-generated documentation for flow CLI commands. ## Task Execution ### `f` (no args) Opens fuzzy finder to browse and run project tasks from `flow.toml`. ### `f ` Run a task directly by name. Additional arguments are passed to the task command. ### `f run ` Explicit task execution (same as `f `). ### `f tasks` List all tasks from the current project's `flow.toml` with descriptions. ### `f rerun` Re-run the last executed task in this project. ### `f last-cmd` Show the last task's input and output/error. ### `f last-cmd-full` Show full details of the last task run (command, status, output). ### `f search` (alias: `f s`) Fuzzy search global commands/tasks from `~/.config/flow/flow.toml`. Useful outside project directories. ### `f match ` (alias: `f m`) Match natural language query to a task using LM Studio. Requires LM Studio running on localhost:1234. Options: - `--model ` - LM Studio model (default: qwen3-8b) - `--port ` - LM Studio port (default: 1234) - `-n, --dry-run` - Show match without running ## Parallel Execution ### `f parallel ` (alias: `f p`) Run multiple tasks in parallel with pretty status display. ```bash # Auto-labeled (uses first word as label) f parallel 'echo hello' 'cargo build' 'cargo test' # Custom labels with label:command syntax f parallel 'build:cargo build' 'test:cargo test' 'lint:cargo clippy' ``` Options: - `-j, --jobs ` - Max concurrent jobs (default: CPU count) - `-f, --fail-fast` - Stop all tasks on first failure Features: - Animated spinners with color cycling - Real-time status (pending/running/success/failure) - Shows last output line during execution - Timing for completed tasks - Full output for failed tasks ## Git & Commits ### `f commit` (alias: `f c`) The default flow commit: stages changes, runs Claude code review, generates commit message, commits, pushes, and syncs AI sessions to gitedit.dev. ```bash f commit # Just commit with Claude review f commit -m "note" # Add author note to commit message ``` Options: - `-n, --no-push` - Skip pushing after commit - `--sync` - Run synchronously (don't delegate to hub) - `--context` - Include AI session context in review (default: off) - `--dry` - Show context without committing - `--codex` - Use Codex instead of Claude for review - `--review-model ` - Choose model (claude-opus, codex-high, codex-mini) - `-m, --message ` - Custom message to include - `-t, --tokens ` - Max tokens for context (default: 1000) ### `f commit-simple` (hidden) Simple AI commit without code review. Just generates commit message and commits. ### `f commit-with-check` (alias: `f cc`, hidden) Like `commit` but without syncing to gitedit.dev. ### `f commits` Browse git commits with AI session metadata using fuzzy search. Options: - `-n, --limit ` - Number of commits (default: 100) - `--all` - Show all branches ### `f fixup` Fix common TOML syntax errors in `flow.toml`. Options: - `-n, --dry-run` - Show fixes without applying ## Process Management ### `f ps` List running flow processes for current project. Options: - `--all` - Show processes across all projects ### `f kill [task]` Stop running flow processes. Options: - `--pid ` - Kill by PID - `--all` - Kill all project processes - `-f, --force` - Force kill (SIGKILL) - `--timeout ` - SIGKILL timeout (default: 5) ### `f logs [task]` View logs from running or recent tasks. Options: - `-f, --follow` - Follow in real-time - `-n, --lines ` - Lines to show (default: 50) - `--all` - All projects - `-l, --list` - List available logs - `-p, --project ` - By project name - `-q, --quiet` - Suppress headers ## Daemons ### `f daemon` (alias: `f d`) Manage background daemons defined in `flow.toml`. Subcommands: - `start ` - Start a daemon - `stop ` - Stop a daemon - `restart ` - Restart a daemon - `status` - Show all daemon status - `list` (alias: `ls`) - List available daemons ## AI Sessions ### `f ai` Manage AI coding sessions (Claude Code, Codex). Subcommands: - `list` (alias: `ls`) - List all sessions - `claude [action]` - Claude sessions only - `codex [action]` - Codex sessions only - `resume [session]` - Resume a session - `save ` - Bookmark current session - `notes ` - Open/create session notes - `remove ` - Remove from tracking - `init` - Initialize .ai folder - `import` - Import existing sessions - `copy [session]` - Copy history to clipboard - `context [session]` - Copy last exchange for context passing ### `f sessions` (alias: `f ss`) Fuzzy search AI sessions across all projects, copy context to clipboard. Options: - `-p, --provider ` - Filter by provider (claude, codex, all) - `-c, --count ` - Number of exchanges to copy - `-l, --list` - Show without copying - `-f, --full` - Full context, ignore checkpoints - `--summarize` - Generate summaries for stale sessions ### `f agent` (alias: `f a`) Invoke kode AI subagents. Subcommands: - `list` (alias: `ls`) - List available agents - `run ` - Run agent (codify, explore, general) ## Project Setup ### `f init` Scaffold a new `flow.toml` in current directory. Options: - `--path ` - Output path ### `f start` Bootstrap project with `.ai/` folder structure: - `.ai/actions/` - Fixer scripts (tracked) - `.ai/skills/` - Shared skills (tracked) - `.ai/tools/` - Shared tools (tracked) - `.ai/flox/` - Flox manifest (tracked) - `.ai/docs/` - AI-generated docs (tracked) - `.ai/agents.md` - Agent instructions (tracked) - `.ai/internal/` - Private data (gitignored) Also materializes `.claude/`, `.codex/`, `.flox/` with symlinks. ### `f doctor` Verify required tools and shell integrations (flox, lin, direnv). ### `f projects` List all registered projects (those with `name` in `flow.toml`). ### `f active [project]` Show or set the active project (fallback for commands outside project dirs). Options: - `-c, --clear` - Clear active project ## Environment ### `f env` Sync project environment and manage env vars via 1focus. Subcommands: - `login` - Authenticate with 1focus - `pull` - Fetch env vars to .env - `push` - Push .env to 1focus - `list` (alias: `ls`) - List env vars - `set ` - Set a var - `delete ` - Delete vars - `status` - Show auth status Options (for pull/push/list/set/delete): - `-e, --environment ` - Environment (dev, staging, production) ## Deployment ### `f deploy` Deploy to various platforms. Subcommands: - `host` (alias: `h`) - Deploy to Linux host via SSH - `--remote-build` - Build on remote instead of syncing artifacts - `--setup` - Run setup script even if deployed - `cloudflare` (alias: `cf`) - Deploy to Cloudflare Workers - `--secrets` - Also set secrets from env_file - `--dev` - Run in dev mode - `setup` - Interactive deploy setup - `railway` - Deploy to Railway - `status` - Show deployment status - `logs` - View deployment logs - `-f, --follow` - Follow logs - `-n, --lines ` - Lines to show - `restart` - Restart deployed service - `stop` - Stop deployed service - `shell` - SSH into host - `set-host ` - Configure host (user@host:port) - `show-host` - Show host configuration - `health` - Check deployment health - `--url ` - Custom URL - `--status ` - Expected status (default: 200) ## Upstream Forks ### `f upstream` (alias: `f up`) Manage upstream fork workflow. Subcommands: - `status` - Show upstream configuration - `setup` - Set up upstream remote and tracking - `-u, --upstream-url ` - Upstream repo URL - `-b, --upstream-branch ` - Branch name (default: main) - `pull` - Pull from upstream into local 'upstream' branch - `-b, --branch ` - Also merge into this branch - `sync` - Full sync: pull upstream, merge, push to origin - `--no-push` - Skip pushing ## Skills & Tools ### `f skills` Manage Codex skills in `.ai/skills/`. Subcommands: - `list` (alias: `ls`) - List skills - `new ` - Create skill - `-d, --description ` - Description - `show ` - Show skill details - `edit ` - Edit in editor - `remove ` - Remove skill - `install ` - Install from registry - `search [query]` - Search registry - `sync` - Sync flow.toml tasks as skills ### `f tools` (alias: `f t`) Manage AI tools in `.ai/tools/*.ts`. Subcommands: - `list` (alias: `ls`) - List tools - `run [args...]` - Run a tool - `new ` - Create tool - `-d, --description ` - Description - `--ai` - Use AI to generate implementation - `edit ` - Edit in editor - `remove ` - Remove tool ## Hub & Server ### `f hub` Ensure hub daemon is running, launch TUI for inspection. Subcommands: - `start` - Start hub daemon - `stop` - Stop hub daemon Options: - `--host ` - Hub host (default: 127.0.0.1) - `--port ` - Hub port (default: 9050) - `--config ` - Config path - `--no-ui` - Skip TUI ### `f server` Start HTTP server for log ingestion and queries. Options: - `--host ` - Bind host (default: 127.0.0.1) - `--port ` - Port (default: 9060) Subcommands: - `foreground` - Run in foreground - `stop` - Stop background server ## Documentation ### `f docs` Manage auto-generated documentation in `.ai/docs/`. Subcommands: - `list` (alias: `ls`) - List documentation files - `status` - Show recent commits and what may need documenting - `sync` - Update sync marker after docs are updated - `-n, --commits ` - Commits to analyze (default: 10) - `--dry` - Dry run - `edit ` - Open doc file in editor The docs are updated by AI assistants as part of the commit flow. When running `f commitWithCheckWithGitedit`, the AI reviews changes and updates: - `commands.md` - CLI command reference - `changelog.md` - Feature changelog - `architecture.md` - Project structure ## Notifications ### `f notify ` Send proposal notification to Lin for approval (human-in-the-loop). Options: - `-t, --title ` - Proposal title - `-c, --context <ctx>` - Context/description - `-e, --expires <secs>` - Expiration (default: 300) ================================================ FILE: .ai/flox/manifest.toml ================================================ version = 1 [install.cargo] pkg-path = "cargo" [install.eza] pkg-path = "eza" ================================================ FILE: .ai/health-checks.json ================================================ { "checks": [ { "name": "share-page", "type": "gitedit-share", "baseUrl": "https://gitedit.dev", "owner": "giteditdev", "repo": "gitedit", "commit": "HEAD" } ] } ================================================ FILE: .ai/recipes/project/bootstrap-core-tools.md ================================================ --- title: Bootstrap Core CLI Stack description: Install rise, seq, and seqd via flow install auto backend. tags: [bootstrap, install, rise, seq] --- Bootstrap the core toolchain onto the same bin directory as `f`. ```sh set -euo pipefail cd ~/code/flow bin_dir="${FLOW_BIN_DIR:-$HOME/.flow/bin}" mkdir -p "$bin_dir" f install rise --backend auto --bin-dir "$bin_dir" --force f install seq --backend auto --bin-dir "$bin_dir" --force f install seqd --backend auto --bin-dir "$bin_dir" --force ls -la "$bin_dir" | rg ' f$| rise$| seq$| seqd$| lin$' || true ``` ================================================ FILE: .ai/recipes/project/install-rise-auto.md ================================================ --- title: Install Rise Via Flow Auto description: Validate that flow install rise resolves through auto backend. tags: [install, rise, registry, parm] --- Install rise using Flow auto backend and print binary path. ```sh set -euo pipefail cd ~/code/flow tmpdir="$(mktemp -d /tmp/flow-install-rise.XXXXXX)" trap 'rm -rf "$tmpdir"' EXIT f install rise --backend auto --bin-dir "$tmpdir" --force ls -l "$tmpdir" "$tmpdir/rise" --version || true ``` ================================================ FILE: .ai/recipes/project/installer-smoke-sh.md ================================================ --- title: Installer Smoke Via sh description: Smoke test myflow installer snippet with pipe-to-sh entrypoint. tags: [install, smoke, sh] --- Run the hosted installer in sh mode (supports curl | sh bootstrap). ```sh set -euo pipefail curl -fsSL https://myflow.sh/install.sh | sh ``` ================================================ FILE: .ai/recipes/project/release-core-toolchain.md ================================================ --- title: Release Core Toolchain description: Publish flow, rise, and seq/seqd to myflow registry. tags: [release, registry, flow, rise, seq] --- ```sh set -euo pipefail cd ~/code/flow f recipe run project:release-flow-registry f recipe run project:release-rise-registry f recipe run project:release-seq-registry ``` ================================================ FILE: .ai/recipes/project/release-flow-registry.md ================================================ --- title: Release Flow To Registry description: Publish flow to myflow registry with explicit version. tags: [release, registry, myflow] --- Cut a new registry release for flow. ```sh set -euo pipefail export FLOW_REGISTRY_URL="${FLOW_REGISTRY_URL:-https://myflow.sh}" cd ~/code/flow if [ -n "${FLOW_VERSION:-}" ]; then f release registry --version "$FLOW_VERSION" --registry "$FLOW_REGISTRY_URL" else f release registry --registry "$FLOW_REGISTRY_URL" fi ``` ================================================ FILE: .ai/recipes/project/release-rise-registry.md ================================================ --- title: Release Rise To Registry description: Publish latest rise binary to myflow registry. tags: [release, registry, rise] --- ```sh set -euo pipefail cd ~/code/rise f release registry ``` ================================================ FILE: .ai/recipes/project/release-seq-registry.md ================================================ --- title: Release Seq+Seqd To Registry description: Stage seq/seqd binaries and publish package seq to myflow registry. tags: [release, registry, seq] --- ```sh set -euo pipefail cd ~/code/seq f release-registry-stage f release registry --no-build ``` ================================================ FILE: .ai/repos.toml ================================================ root = "~/repos" [[repos]] owner = "rust-lang" repo = "crates.io-index" url = "https://github.com/rust-lang/crates.io-index" [[repos]] owner = "dtolnay" repo = "anyhow" url = "https://github.com/dtolnay/anyhow" [[repos]] owner = "tokio-rs" repo = "tokio" url = "https://github.com/tokio-rs/tokio" ================================================ FILE: .ai/tasks/flow/bench-cli/main.mbt ================================================ // title: Flow CLI Bench // description: Quick CLI benchmark for high-frequency Flow entry points. // tags: [flow,bench,latency] fn project_root() -> String { match @sys.get_env_var("FLOW_AI_TASK_PROJECT_ROOT") { Some(root) => root None => "../../../.." } } async fn run_step(label : String, command : String, root : String) -> Unit raise { println("==> " + label) println(" " + command) let code = @process.run( "sh", ["-lc", command], stdin=@stdio.stdin, stdout=@stdio.stdout, stderr=@stdio.stderr, cwd=root, ) if code != 0 { abort("step failed: " + label + " (exit " + code.to_string() + ")") } } async fn main raise { let root = project_root() let bench_script = "set -euo pipefail; " + "bin=${FLOW_BIN:-./target/release/f}; " + "if [ ! -x \"$bin\" ]; then bin=./target/debug/f; fi; " + "runs=${FLOW_BENCH_ITERATIONS:-20}; " + "warmup=${FLOW_BENCH_WARMUP:-5}; " + "echo \"bench bin: $bin\"; " + "echo \"runs: $runs warmup: $warmup\"; " + "if command -v hyperfine >/dev/null 2>&1; then " + "hyperfine --warmup \"$warmup\" --runs \"$runs\" " + "\"$bin --help >/dev/null\" " + "\"$bin tasks list >/dev/null\" " + "\"$bin tasks dupes >/dev/null\"; " + "else " + "echo 'hyperfine not found; fallback single-run timing'; " + "for cmd in '--help' 'tasks list' 'tasks dupes'; do " + "echo \"timing: $bin $cmd\"; /usr/bin/time -l sh -lc \"$bin $cmd >/dev/null\"; " + "done; " + "fi" run_step("cli benchmark", bench_script, root) println("flow/bench-cli: ok") } ================================================ FILE: .ai/tasks/flow/bench-cli/moon.mod.json ================================================ { "name": "nikiv/flow-ai-tasks", "version": "0.1.0", "deps": { "moonbitlang/async": "0.16.6", "moonbitlang/x": "0.4.40" } } ================================================ FILE: .ai/tasks/flow/bench-cli/moon.pkg.json ================================================ { "is-main": true, "import": [ "moonbitlang/async", "moonbitlang/async/process", "moonbitlang/async/stdio", "moonbitlang/x/sys" ], "support-targets": ["native"] } ================================================ FILE: .ai/tasks/flow/dev-check/main.mbt ================================================ // title: Flow Dev Check // description: Fast local quality gate for Flow code changes. // tags: [flow,dev,check] fn project_root() -> String { match @sys.get_env_var("FLOW_AI_TASK_PROJECT_ROOT") { Some(root) => root None => "../../../.." } } async fn run_step(label : String, command : String, root : String) -> Unit raise { println("==> " + label) println(" " + command) let code = @process.run( "sh", ["-lc", command], stdin=@stdio.stdin, stdout=@stdio.stdout, stderr=@stdio.stderr, cwd=root, ) if code != 0 { abort("step failed: " + label + " (exit " + code.to_string() + ")") } } async fn main raise { let root = project_root() run_step("cargo check", "cargo check --all-targets", root) run_step( "targeted ai task test", "cargo test ai_tasks::tests::parses_metadata_comments -- --nocapture", root, ) run_step("tasks help smoke", "./target/debug/f tasks --help >/dev/null", root) println("flow/dev-check: ok") } ================================================ FILE: .ai/tasks/flow/dev-check/moon.mod.json ================================================ { "name": "nikiv/flow-ai-tasks", "version": "0.1.0", "deps": { "moonbitlang/async": "0.16.6", "moonbitlang/x": "0.4.40" } } ================================================ FILE: .ai/tasks/flow/dev-check/moon.pkg.json ================================================ { "is-main": true, "import": [ "moonbitlang/async", "moonbitlang/async/process", "moonbitlang/async/stdio", "moonbitlang/x/sys" ], "support-targets": ["native"] } ================================================ FILE: .ai/tasks/flow/noop/main.mbt ================================================ // title: Flow Noop // description: Minimal AI task for runtime overhead benchmarking. // tags: [flow,bench,noop] fn main { () } ================================================ FILE: .ai/tasks/flow/noop/moon.mod.json ================================================ { "name": "nikiv/flow-ai-tasks", "version": "0.1.0", "deps": { "moonbitlang/async": "0.16.6", "moonbitlang/x": "0.4.40" } } ================================================ FILE: .ai/tasks/flow/noop/moon.pkg.json ================================================ { "is-main": true, "support-targets": ["native"] } ================================================ FILE: .ai/tasks/flow/pr-ready/main.mbt ================================================ // title: Flow PR Ready // description: Pre-PR gate: compile, task checks, docs parity, gitignore hygiene. // tags: [flow,pr,gate] fn project_root() -> String { match @sys.get_env_var("FLOW_AI_TASK_PROJECT_ROOT") { Some(root) => root None => "../../../.." } } async fn run_step(label : String, command : String, root : String) -> Unit raise { println("==> " + label) println(" " + command) let code = @process.run( "sh", ["-lc", command], stdin=@stdio.stdin, stdout=@stdio.stdout, stderr=@stdio.stderr, cwd=root, ) if code != 0 { abort("step failed: " + label + " (exit " + code.to_string() + ")") } } async fn main raise { let root = project_root() run_step("dev check", "./target/debug/f ai:flow/dev-check", root) run_step( "docs parity gate", "set -euo pipefail; " + "src_changed=$(git diff --name-only HEAD -- src/cli.rs src/tasks.rs src/palette.rs src/ai_tasks.rs || true); " + "if [ -n \"$src_changed\" ]; then " + "docs_changed=$(git diff --name-only HEAD -- docs/commands/readme.md docs/commands/tasks.md docs/commands/recipe.md || true); " + "if [ -z \"$docs_changed\" ]; then echo 'expected docs/commands updates for CLI/task changes' >&2; exit 1; fi; " + "fi; " + "echo 'docs parity: ok'", root, ) run_step( "gitignore hygiene gate", "set -euo pipefail; " + "if git diff -- .gitignore | grep -E '\\.beads/|\\.rise/|\\.ai/todos/\\*\\.bike' >/dev/null; then " + "echo 'blocked personal tooling pattern detected in .gitignore diff' >&2; exit 1; " + "fi; " + "echo 'gitignore hygiene: ok'", root, ) println("flow/pr-ready: ok") } ================================================ FILE: .ai/tasks/flow/pr-ready/moon.mod.json ================================================ { "name": "nikiv/flow-ai-tasks", "version": "0.1.0", "deps": { "moonbitlang/async": "0.16.6", "moonbitlang/x": "0.4.40" } } ================================================ FILE: .ai/tasks/flow/pr-ready/moon.pkg.json ================================================ { "is-main": true, "import": [ "moonbitlang/async", "moonbitlang/async/process", "moonbitlang/async/stdio", "moonbitlang/x/sys" ], "support-targets": ["native"] } ================================================ FILE: .ai/tasks/flow/regression-smoke/main.mbt ================================================ // title: Flow Regression Smoke // description: Validate task discovery and AI task execution in a fresh temp project. // tags: [flow,smoke,regression] fn project_root() -> String { match @sys.get_env_var("FLOW_AI_TASK_PROJECT_ROOT") { Some(root) => root None => "../../../.." } } async fn run_step(label : String, command : String, root : String) -> Unit raise { println("==> " + label) println(" " + command) let code = @process.run( "sh", ["-lc", command], stdin=@stdio.stdin, stdout=@stdio.stdout, stderr=@stdio.stderr, cwd=root, ) if code != 0 { abort("step failed: " + label + " (exit " + code.to_string() + ")") } } async fn main raise { let root = project_root() let smoke_script = "set -euo pipefail; " + "bin=${FLOW_BIN:-$PWD/target/debug/f}; " + "case \"$bin\" in /*) ;; *) bin=\"$PWD/$bin\" ;; esac; " + "if [ ! -x \"$bin\" ]; then bin=$PWD/target/release/f; fi; " + "tmp=$(mktemp -d /tmp/flow-task-smoke.XXXXXX); " + "trap 'rm -rf \"$tmp\"' EXIT; " + "printf '%s\\n' '[[tasks]]' 'name = \"hello\"' 'command = \"echo hello-flow\"' > \"$tmp/flow.toml\"; " + "(cd \"$tmp\" && \"$bin\" tasks init-ai --root . >/dev/null); " + "(cd \"$tmp\" && \"$bin\" tasks list | grep -q 'ai:starter'); " + "(cd \"$tmp\" && out=$(\"$bin\" starter) && case \"$out\" in *'starter ai task: ok'*) : ;; *) echo 'starter output mismatch' >&2; exit 1 ;; esac); " + "(cd \"$tmp\" && out=$(\"$bin\" hello) && case \"$out\" in *'hello-flow'*) : ;; *) echo 'hello output mismatch' >&2; exit 1 ;; esac); " + "echo 'regression smoke passed'" run_step("temp project smoke", smoke_script, root) println("flow/regression-smoke: ok") } ================================================ FILE: .ai/tasks/flow/regression-smoke/moon.mod.json ================================================ { "name": "nikiv/flow-ai-tasks", "version": "0.1.0", "deps": { "moonbitlang/async": "0.16.6", "moonbitlang/x": "0.4.40" } } ================================================ FILE: .ai/tasks/flow/regression-smoke/moon.pkg.json ================================================ { "is-main": true, "import": [ "moonbitlang/async", "moonbitlang/async/process", "moonbitlang/async/stdio", "moonbitlang/x/sys" ], "support-targets": ["native"] } ================================================ FILE: .ai/tasks/flow/release-preflight/main.mbt ================================================ // title: Flow Release Preflight // description: Build release binary and validate core release smoke checks. // tags: [flow,release,preflight] fn project_root() -> String { match @sys.get_env_var("FLOW_AI_TASK_PROJECT_ROOT") { Some(root) => root None => "../../../.." } } async fn run_step(label : String, command : String, root : String) -> Unit raise { println("==> " + label) println(" " + command) let code = @process.run( "sh", ["-lc", command], stdin=@stdio.stdin, stdout=@stdio.stdout, stderr=@stdio.stderr, cwd=root, ) if code != 0 { abort("step failed: " + label + " (exit " + code.to_string() + ")") } } async fn main raise { let root = project_root() run_step("build release f", "cargo build --release --bin f", root) run_step("release version", "./target/release/f --version", root) run_step("release tasks help", "./target/release/f tasks --help >/dev/null", root) run_step( "release regression smoke", "FLOW_BIN=./target/release/f ./target/release/f ai:flow/regression-smoke", root, ) println("flow/release-preflight: ok") } ================================================ FILE: .ai/tasks/flow/release-preflight/moon.mod.json ================================================ { "name": "nikiv/flow-ai-tasks", "version": "0.1.0", "deps": { "moonbitlang/async": "0.16.6", "moonbitlang/x": "0.4.40" } } ================================================ FILE: .ai/tasks/flow/release-preflight/moon.pkg.json ================================================ { "is-main": true, "import": [ "moonbitlang/async", "moonbitlang/async/process", "moonbitlang/async/stdio", "moonbitlang/x/sys" ], "support-targets": ["native"] } ================================================ FILE: .ai/todos/todos.json ================================================ [ { "id": "c1b0f1e0e6d84f33b169b9b0f87f3c4e", "title": "Evaluate Rolldown for builds and deployment speedups", "status": "pending", "created_at": "2026-01-02T19:30:00Z", "updated_at": null, "note": "Compare Vite/Rollup vs Rolldown (Rust) and list build cache, asset diffing, and deploy pipeline optimizations.", "session": null }, { "id": "72529e916fd74131ba746d6a962f53f6", "title": "Re-run review: review timed out for commit d076aea", "status": "completed", "created_at": "2026-02-13T10:13:24.836444+00:00", "updated_at": "2026-02-13T10:46:08.595809+00:00", "note": "Source: flow review\nCommit: d076aead403da676b24cc8f2631cb217e7f5a004\nModel: gpt-5.1-codex-max\nReview summary: Codex review timed out after 120s\n\nRe-run review: review timed out for commit d076aea", "session": null, "external_ref": "flow-review-issue-3b7fc9b7a8dc" }, { "id": "26717fd7a4774eceb418c88579de547c", "title": "Re-run review: review timed out for commit 6ace98e", "status": "completed", "created_at": "2026-02-13T14:20:44.173471+00:00", "updated_at": "2026-02-16T21:59:53.299632+00:00", "note": "Source: flow review\nCommit: 6ace98ebb6984d2b237007c964066d50f6faa153\nModel: gpt-5.1-codex-max\nReview summary: Codex review timed out after 300s\n\nRe-run review: review timed out for commit 6ace98e", "session": null, "external_ref": "flow-review-issue-ce0f9bb6b555" }, { "id": "cf5fc5b65e62468b8f72d1cf88fac6e0", "title": "Re-run review: review timed out for commit 311eea9", "status": "completed", "created_at": "2026-02-14T09:28:44.871217+00:00", "updated_at": "2026-02-14T09:31:15.179148+00:00", "note": "Source: flow review\nCommit: 311eea95538dfc9c87d6b2e5f98a7890a0803465\nModel: gpt-5.1-codex-max\nReview summary: Codex review timed out after 300s\n\nRe-run review: review timed out for commit 311eea9", "session": null, "external_ref": "flow-review-issue-888c93874436" }, { "id": "1d57246970fd4297b5bba7e2bb1c4ce3", "title": "Re-run review: review timed out for commit 2177d65", "status": "completed", "created_at": "2026-02-14T16:21:46.629833+00:00", "updated_at": "2026-02-16T21:59:53.395640+00:00", "note": "Source: flow review\nCommit: 2177d65d15ebc28e13152b31413aa8263e8f8f71\nModel: gpt-5.1-codex-max\nReview summary: Codex review timed out after 300s\n\nRe-run review: review timed out for commit 2177d65", "session": null, "external_ref": "flow-review-issue-e11c99ffe84e" }, { "id": "f21c30c0925f48ac858e716fa4ca5473", "title": "[P2] AI branch selection errors on valid `none` response — /Users/nikiv/code/flow/src/branches.rs:93-105", "status": "pending", "created_at": "2026-02-15T14:53:56.021590+00:00", "updated_at": null, "note": "Source: flow review\nCommit: 0f2d9648acd4e91cdfa807d379adcc834937d3a9\nModel: gpt-5.1-codex-max\n\n[P2] AI branch selection errors on valid `none` response — /Users/nikiv/code/flow/src/branches.rs:93-105", "session": null, "external_ref": "flow-review-issue-cb4f1393f4e9" }, { "id": "e52b2941b9b94e0289ba8d01f7e2fa83", "title": "[P2] Missing project param returns 500 instead of client error — /Users/nikiv/code/flow/src/server.rs:900-907", "status": "pending", "created_at": "2026-02-16T10:41:04.444182+00:00", "updated_at": null, "note": "Source: flow review\nCommit: 4133ea3a77aa72f21abd21c4e26799bac3278f34\nModel: gpt-5.1-codex-max\n\n[P2] Missing project param returns 500 instead of client error — /Users/nikiv/code/flow/src/server.rs:900-907", "session": null, "external_ref": "flow-review-issue-cc5166e8114b" }, { "id": "d4a12dd1ab3449c5953b4e3e3b572e89", "title": "[P2] JJ default remote ignores new git.remote setting — /Users/nikiv/code/flow/src/cli.rs:1718-1726", "status": "pending", "created_at": "2026-02-16T18:58:58.634200+00:00", "updated_at": null, "note": "Source: flow review\nCommit: 0b6424d81eeee048a5e1507cc67094ea4e883819\nModel: gpt-5.1-codex-max\n\n[P2] JJ default remote ignores new git.remote setting — /Users/nikiv/code/flow/src/cli.rs:1718-1726", "session": null, "external_ref": "flow-review-issue-aa7d77e8a06c" }, { "id": "ccc4555e52c745e4aa017237f10a8c97", "title": "[P1] Collect gitedit sessions after creating commit — /Users/nikiv/code/flow/src/commit.rs:4299-4311", "status": "pending", "created_at": "2026-02-16T19:43:51.443004+00:00", "updated_at": null, "note": "Source: flow review\nCommit: 18a4bde4ca0433b7b3689560cfd6a949ebfd0565\nModel: gpt-5.1-codex-max\n\n[P1] Collect gitedit sessions after creating commit — /Users/nikiv/code/flow/src/commit.rs:4299-4311", "session": null, "external_ref": "flow-review-issue-4f55a14a7a31" }, { "id": "f264371559114db59152e00a394a999b", "title": "[P1] Honor recipe shell selection when running commands — /Users/nikiv/code/flow/src/recipe.rs:122-126", "status": "completed", "created_at": "2026-02-17T11:40:29.148363+00:00", "updated_at": "2026-02-17T14:43:38.741376+00:00", "note": "Source: flow review\nCommit: c52538d993835214221eb89252777e1e64027454\nModel: gpt-5.1-codex-max\n\n[P1] Honor recipe shell selection when running commands — /Users/nikiv/code/flow/src/recipe.rs:122-126", "session": null, "external_ref": "flow-review-issue-551b4c7441b9", "priority": "P4" }, { "id": "e2b50f22152841f299d47ab83dc2e264", "title": "[P2] Global recipes default to project directory — /Users/nikiv/code/flow/src/recipe.rs:560-569", "status": "completed", "created_at": "2026-02-17T11:40:29.148363+00:00", "updated_at": "2026-02-17T14:43:38.749300+00:00", "note": "Source: flow review\nCommit: c52538d993835214221eb89252777e1e64027454\nModel: gpt-5.1-codex-max\n\n[P2] Global recipes default to project directory — /Users/nikiv/code/flow/src/recipe.rs:560-569", "session": null, "external_ref": "flow-review-issue-658624948e1a", "priority": "P4" }, { "id": "1531571a23a44211837aa0a3f7692a6f", "title": "[P2] reviews-todo fix ignores configured Codex binary — /Users/nikiv/code/flow/src/reviews_todo.rs:262-265", "status": "completed", "created_at": "2026-02-17T11:40:29.148363+00:00", "updated_at": "2026-02-17T14:43:38.756548+00:00", "note": "Source: flow review\nCommit: c52538d993835214221eb89252777e1e64027454\nModel: gpt-5.1-codex-max\n\n[P2] reviews-todo fix ignores configured Codex binary — /Users/nikiv/code/flow/src/reviews_todo.rs:262-265", "session": null, "external_ref": "flow-review-issue-9f11354ed9dd", "priority": "P4" }, { "id": "3aa07bb3ec6344cfbb2e11072ef39604", "title": "[P2] Run bootstrap after registry installs — /Users/nikiv/code/flow/scripts/install.sh:637-646", "status": "completed", "created_at": "2026-02-17T13:40:09.528742+00:00", "updated_at": "2026-02-17T14:43:38.764137+00:00", "note": "Source: flow review\nCommit: 96f37f21bc5bcf51fafe38fe0fc8343a3cbb916b\nModel: gpt-5.1-codex-max\n\n[P2] Run bootstrap after registry installs — /Users/nikiv/code/flow/scripts/install.sh:637-646", "session": null, "external_ref": "flow-review-issue-245de484c8ee", "priority": "P4" }, { "id": "0a7bc4060d23470d96cac5ea043a6b0a", "title": "[P1] Scoped parsing blocks ':' or '/' task names — /var/folders/69/jnm2pqrx12103z_f8hh3_d1w0000gn/T/.tmpDgAgRL/repo/src/...", "status": "completed", "created_at": "2026-02-17T14:32:04.676369+00:00", "updated_at": "2026-02-17T14:42:36.827744+00:00", "note": "Source: flow review\nCommit: 527049ff7b73886705ded5e4105e35ca7a76d01b\nModel: gpt-5.1-codex-max\n\n[P1] Scoped parsing blocks ':' or '/' task names — /var/folders/69/jnm2pqrx12103z_f8hh3_d1w0000gn/T/.tmpDgAgRL/repo/src/tasks.rs:728-741", "session": null, "external_ref": "flow-review-issue-7cf264b052d8", "priority": "P4" }, { "id": "8f2bdf3f63f440f1abbb034634fe2eae", "title": "Re-run review: review timed out for commit 527049f", "status": "completed", "created_at": "2026-02-17T14:35:26.536392+00:00", "updated_at": "2026-02-17T14:43:57.289970+00:00", "note": "Source: flow review\nCommit: 527049ff7b73886705ded5e4105e35ca7a76d01b\nModel: gpt-5.1-codex-max\nReview summary: Codex review timed out after 360s\n\nRe-run review: review timed out for commit 527049f", "session": null, "external_ref": "flow-review-issue-c02170490800", "priority": "P4" } ] ================================================ FILE: .flox.disabled ================================================ flox activate repeatedly failed ================================================ FILE: .github/workflows/canary.yml ================================================ name: Canary on: push: branches: - main paths-ignore: - "docs/**" - "**/*.md" workflow_dispatch: permissions: contents: write concurrency: group: canary cancel-in-progress: true env: CARGO_TERM_COLOR: always jobs: build: timeout-minutes: 40 env: # Don't reference `secrets.*` in `if:` expressions; GitHub rejects that at workflow-parse time. MACOS_SIGN_P12_B64: ${{ secrets.MACOS_SIGN_P12_B64 }} MACOS_SIGN_P12_PASSWORD: ${{ secrets.MACOS_SIGN_P12_PASSWORD }} MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} strategy: matrix: include: - target: x86_64-apple-darwin os: macos-latest - target: aarch64-apple-darwin os: macos-latest - target: x86_64-unknown-linux-gnu os: ubuntu-latest - target: aarch64-unknown-linux-gnu os: ubuntu-latest runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" cache-on-failure: true - name: Install cross-compilation tools (Linux ARM) if: matrix.target == 'aarch64-unknown-linux-gnu' run: | sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu - name: Build run: | if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc fi cargo build --release --target ${{ matrix.target }} --bin f - name: Import code-signing certificates (macOS) if: runner.os == 'macOS' && env.MACOS_SIGN_P12_B64 != '' uses: apple-actions/import-codesign-certs@v3 with: p12-file-base64: ${{ env.MACOS_SIGN_P12_B64 }} p12-password: ${{ env.MACOS_SIGN_P12_PASSWORD }} - name: Codesign (macOS) if: runner.os == 'macOS' && env.MACOS_SIGN_P12_B64 != '' && env.MACOS_SIGN_IDENTITY != '' run: | BIN="target/${{ matrix.target }}/release/f" codesign --force --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$BIN" codesign -vvv --strict "$BIN" - name: Package run: | mkdir -p dist cp target/${{ matrix.target }}/release/f dist/ cd dist tar -czvf flow-${{ matrix.target }}.tar.gz f cd .. - name: Upload artifact uses: actions/upload-artifact@v4 with: name: flow-${{ matrix.target }} path: dist/flow-${{ matrix.target }}.tar.gz build-linux-host-simd: timeout-minutes: 40 runs-on: [self-hosted, linux, x64, ci-1focus] env: RUSTFLAGS: -C target-cpu=native steps: - name: Checkout uses: actions/checkout@v4 - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: x86_64-unknown-linux-gnu - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" cache-on-failure: true - name: Build (Linux host SIMD) run: cargo build --release --target x86_64-unknown-linux-gnu --features linux-host-simd-json --bin f - name: Package run: | mkdir -p dist cp target/x86_64-unknown-linux-gnu/release/f dist/ cd dist tar -czvf flow-x86_64-unknown-linux-gnu-linux-host-simd.tar.gz f cd .. - name: Upload artifact uses: actions/upload-artifact@v4 with: name: flow-x86_64-unknown-linux-gnu-linux-host-simd path: dist/flow-x86_64-unknown-linux-gnu-linux-host-simd.tar.gz release: needs: [build, build-linux-host-simd] timeout-minutes: 20 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Prepare release files run: | mkdir -p release find artifacts -name "*.tar.gz" -exec cp {} release/ \; cd release sha256sum *.tar.gz > checksums.txt cat checksums.txt - name: Move canary tag to this commit run: | git tag -f canary "${GITHUB_SHA}" git push origin -f refs/tags/canary - name: Create/Update Canary Release uses: softprops/action-gh-release@v2 with: tag_name: canary name: Canary prerelease: true generate_release_notes: false files: release/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/nightly-validation.yml ================================================ name: Nightly Validation on: schedule: - cron: "27 3 * * *" workflow_dispatch: permissions: contents: read env: CARGO_TERM_COLOR: always jobs: deps-freshness: timeout-minutes: 30 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Smoke vendor trims run: scripts/vendor/apply-trims.sh - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Check dependency freshness run: python3 scripts/deps_check.py build: timeout-minutes: 50 strategy: fail-fast: false matrix: include: - target: x86_64-apple-darwin os: macos-latest - target: aarch64-apple-darwin os: macos-latest - target: x86_64-unknown-linux-gnu os: ubuntu-latest - target: aarch64-unknown-linux-gnu os: ubuntu-latest runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" cache-on-failure: true - name: Install cross-compilation tools (Linux ARM) if: matrix.target == 'aarch64-unknown-linux-gnu' run: | sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu - name: Build run: | if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc fi cargo build --release --target ${{ matrix.target }} --bin f build-linux-host-simd: timeout-minutes: 50 runs-on: [self-hosted, linux, x64, ci-1focus] env: RUSTFLAGS: -C target-cpu=native steps: - name: Checkout uses: actions/checkout@v4 - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: x86_64-unknown-linux-gnu - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" cache-on-failure: true - name: Build (Linux host SIMD) run: cargo build --release --target x86_64-unknown-linux-gnu --features linux-host-simd-json --bin f ================================================ FILE: .github/workflows/pr-fast.yml ================================================ name: PR Fast Check on: pull_request: branches: - main paths-ignore: - "docs/**" - "**/*.md" permissions: contents: read concurrency: group: pr-fast-${{ github.event.pull_request.number }} cancel-in-progress: true env: CARGO_TERM_COLOR: always jobs: check-linux: runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@v4 - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Smoke vendor trims run: scripts/vendor/apply-trims.sh - name: Install Rust uses: dtolnay/rust-toolchain@stable - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" cache-on-failure: true - name: Check flowd run: cargo check -p flowd - name: Check flowd SIMD feature lane run: cargo check -p flowd --features linux-host-simd-json - name: Check seq bridge run: cargo check -p seq_everruns_bridge - name: Compile seq bridge tests run: cargo test -p seq_everruns_bridge --no-run - name: Build release CLI env: CARGO_INCREMENTAL: 0 run: cargo build --release --bin f - name: Benchmark release CLI startup run: python3 scripts/bench-cli-startup.py --iterations 5 --warmup 1 --flow-bin ./target/release/f --json-out out/bench/cli-startup.json - name: Enforce startup latency thresholds run: python3 scripts/check_cli_startup_thresholds.py out/bench/cli-startup.json ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - "v*" permissions: contents: write env: CARGO_TERM_COLOR: always jobs: build: timeout-minutes: 40 env: # Don't reference `secrets.*` in `if:` expressions; GitHub rejects that at workflow-parse time. MACOS_SIGN_P12_B64: ${{ secrets.MACOS_SIGN_P12_B64 }} MACOS_SIGN_P12_PASSWORD: ${{ secrets.MACOS_SIGN_P12_PASSWORD }} MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} strategy: matrix: include: - target: x86_64-apple-darwin os: macos-latest - target: aarch64-apple-darwin os: macos-latest - target: x86_64-unknown-linux-gnu os: ubuntu-latest - target: aarch64-unknown-linux-gnu os: ubuntu-latest runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 - name: Verify release tag matches Cargo version run: python3 scripts/check_release_tag_version.py "${GITHUB_REF_NAME}" - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" cache-on-failure: true - name: Install cross-compilation tools (Linux ARM) if: matrix.target == 'aarch64-unknown-linux-gnu' run: | sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu - name: Build run: | if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc fi cargo build --release --target ${{ matrix.target }} --bin f - name: Import code-signing certificates (macOS) if: runner.os == 'macOS' && env.MACOS_SIGN_P12_B64 != '' uses: apple-actions/import-codesign-certs@v3 with: p12-file-base64: ${{ env.MACOS_SIGN_P12_B64 }} p12-password: ${{ env.MACOS_SIGN_P12_PASSWORD }} - name: Codesign (macOS) if: runner.os == 'macOS' && env.MACOS_SIGN_P12_B64 != '' && env.MACOS_SIGN_IDENTITY != '' run: | BIN="target/${{ matrix.target }}/release/f" codesign --force --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$BIN" codesign -vvv --strict "$BIN" - name: Package run: | mkdir -p dist cp target/${{ matrix.target }}/release/f dist/ cd dist tar -czvf flow-${{ matrix.target }}.tar.gz f cd .. - name: Upload artifact uses: actions/upload-artifact@v4 with: name: flow-${{ matrix.target }} path: dist/flow-${{ matrix.target }}.tar.gz build-linux-host-simd: timeout-minutes: 40 runs-on: [self-hosted, linux, x64, ci-1focus] env: RUSTFLAGS: -C target-cpu=native steps: - name: Checkout uses: actions/checkout@v4 - name: Verify release tag matches Cargo version run: python3 scripts/check_release_tag_version.py "${GITHUB_REF_NAME}" - name: Validate readme casing run: scripts/ci/check-readme-case.sh - name: Cache vendored deps uses: actions/cache@v4 with: path: | .vendor/flow-vendor lib/vendor lib/vendor-manifest key: ${{ runner.os }}-${{ runner.arch }}-vendor-${{ hashFiles('vendor.lock.toml') }} - name: Verify pinned vendor commit is published run: scripts/vendor/vendor-repo.sh verify-pinned-origin - name: Hydrate vendored deps run: scripts/vendor/vendor-repo.sh hydrate - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: x86_64-unknown-linux-gnu - name: Cache Rust build uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" cache-on-failure: true - name: Build (Linux host SIMD) run: cargo build --release --target x86_64-unknown-linux-gnu --features linux-host-simd-json --bin f - name: Package run: | mkdir -p dist cp target/x86_64-unknown-linux-gnu/release/f dist/ cd dist tar -czvf flow-x86_64-unknown-linux-gnu-linux-host-simd.tar.gz f cd .. - name: Upload artifact uses: actions/upload-artifact@v4 with: name: flow-x86_64-unknown-linux-gnu-linux-host-simd path: dist/flow-x86_64-unknown-linux-gnu-linux-host-simd.tar.gz release: needs: [build, build-linux-host-simd] timeout-minutes: 20 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Prepare release files run: | mkdir -p release find artifacts -name "*.tar.gz" -exec cp {} release/ \; cd release sha256sum *.tar.gz > checksums.txt cat checksums.txt - name: Create Release uses: softprops/action-gh-release@v2 with: files: release/* generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # flow .cargo/ .ai/internal/ .ai/web/ .ai/reviews/ .ai/test/ .ai/tmp/ .ai/cache/ .ai/artifacts/ .ai/traces/ .ai/generated/ .ai/scratch/ .ai/skills/ # keep generated skills ignored by default, but track curated flow-default skills !.ai/skills/ .ai/skills/* !.ai/skills/env/ .ai/skills/env/* !.ai/skills/env/skill.md !.ai/skills/quality-bun-feature-delivery/ .ai/skills/quality-bun-feature-delivery/* !.ai/skills/quality-bun-feature-delivery/skill.md !.ai/skills/pr-markdown-body-file/ .ai/skills/pr-markdown-body-file/* !.ai/skills/pr-markdown-body-file/skill.md .ai/todos/*.bike .claude/ .codex/ .flox/ .flow/ .flow_diff_review.tmp .vendor/ lib/vendor/ lib/vendor-history/ !lib/vendor-manifest/ !lib/vendor-manifest/** # core .DS_Store .env .env*.local .env.production output out/ dist target .idea .cache .output node_modules package-lock.json yarn.lock .vercel env-local/ *.db .repo_ignore i.* i-* i/ internal/ past.* past-* past/ *.log private .blade .npm-cache /npm/**/vendor/ /npm/flow-*/ /test-data testing/ desktop/.tauri-dev.json .ai/review-log.jsonl .rise/ .ai/state.json .ai/tasks/**/.mooncakes/ .ai/tasks/**/_build/ bench/moon_ffi_boundary/_build/ bench/moon_ffi_boundary/moon.pkg.json .beads/ *.pyc __pycache__/ # explain-commits output is local/generated by default docs/commits/* !docs/commits/ !docs/commits/readme.md !docs/commits/.gitkeep ================================================ FILE: .goreleaser.yaml ================================================ project_name: flow before: hooks: - rustup default stable - cargo install --locked cargo-zigbuild builds: - id: f builder: rust binary: f tool: cargo command: zigbuild flags: - --release targets: - aarch64-apple-darwin - x86_64-apple-darwin - aarch64-unknown-linux-gnu - x86_64-unknown-linux-gnu - id: lin builder: rust binary: lin tool: cargo command: zigbuild flags: - --release targets: - aarch64-apple-darwin - x86_64-apple-darwin - aarch64-unknown-linux-gnu - x86_64-unknown-linux-gnu archives: - id: default format: tar.gz name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" builds: - f - lin files: - LICENSE* - README* checksum: name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" changelog: sort: asc ================================================ FILE: .pi/extensions/test-extensibility.ts ================================================ /** * Test Extension - Demonstrates pi-mono extensibility * * Run with: pi (in ~/code/flow directory) * Then ask: "use the counter tool" or "run a bash command" */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent" import { Type } from "@sinclair/typebox" export default function (pi: ExtensionAPI) { console.log("[test-ext] Extension loaded!") // ============================================ // 1. CUSTOM TOOL // ============================================ let count = 0 pi.registerTool({ name: "counter", label: "Counter", description: "A simple counter tool. Actions: get, increment, decrement, reset", parameters: Type.Object({ action: Type.Union([ Type.Literal("get"), Type.Literal("increment"), Type.Literal("decrement"), Type.Literal("reset"), ]), amount: Type.Optional(Type.Number({ description: "Amount to add/subtract (default 1)" })), }), async execute(_toolCallId, params, onUpdate, _ctx, _signal) { const amount = params.amount ?? 1 // Stream progress onUpdate?.({ content: [{ type: "text", text: `Processing ${params.action}...` }] }) switch (params.action) { case "increment": count += amount break case "decrement": count -= amount break case "reset": count = 0 break } return { content: [{ type: "text", text: `Counter is now: ${count}` }], details: { count, action: params.action }, } }, }) // ============================================ // 2. EVENT HOOKS // ============================================ // Log all tool calls pi.on("tool_call", async (event, ctx) => { console.log(`[test-ext] Tool called: ${event.toolName}`) // Example: warn on dangerous bash commands (but don't block) if (event.toolName === "bash") { const cmd = event.input.command as string if (cmd.includes("rm ")) { ctx.ui.notify("Careful with rm commands!", "warn") } } return undefined // Don't block }) // Log turn completions pi.on("turn_end", async (event, _ctx) => { console.log(`[test-ext] Turn ended. Tokens: ${event.usage?.inputTokens ?? 0} in, ${event.usage?.outputTokens ?? 0} out`) }) // ============================================ // 3. CUSTOM COMMAND // ============================================ pi.registerCommand("count", { description: "Show the current counter value", handler: async (_args, ctx) => { ctx.ui.notify(`Counter: ${count}`, "info") }, }) // ============================================ // 4. SESSION EVENTS // ============================================ pi.on("session_start", async (_event, _ctx) => { console.log("[test-ext] Session started") count = 0 // Reset on new session }) } ================================================ FILE: AGENTS.md.bak ================================================ ================================================ FILE: Cargo.toml ================================================ [package] name = "flowd" version = "0.1.3" edition = "2024" repository = "https://github.com/nikivdev/flow" autobins = false [workspace] members = [ ".", "crates/ai_taskd_client", "crates/flow_commit_scan", "crates/opentui-lite", "crates/seq_client", "crates/seq_everruns_bridge", ] default-members = ["."] resolver = "2" [[bin]] name = "f" path = "src/main.rs" [[bin]] name = "flow" path = "src/main.rs" [[bin]] name = "lin" path = "src/bin/lin.rs" [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "json", "query", "tokio"] } tower-http = { version = "0.6", features = ["cors"] } anyhow = "1" clap = { version = "4", features = ["derive"] } futures = "0.3" ignore = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" rmp-serde = "1" tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["sync"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } toml = "1" ratatui = { version = "0.30", default-features = false, features = ["crossterm"] } crossterm = "0.29" reqwest = { version = "0.13", default-features = false, features = ["json", "blocking", "query", "rustls"] } which = "8" rusqlite = { version = "0.39", features = ["bundled"] } notify = "8" notify-debouncer-mini = "0.7" shellexpand = "3" shell-words = "1" base64 = "0.22" bs58 = "0.5.1" sha2 = "0.10" hex = "0.4" url = "2" strip-ansi-escapes = "0.2" portable-pty = "0.9" regex = "1.12.2" chrono = "0.4.42" dirs = "6.0.0" uuid = { version = "1", features = ["v4"] } tempfile = "3" ctrlc = "3.4" rpassword = "7" atty = "0.2" hmac = "0.12" sha1 = "0.10" data-encoding = "2.6" opentui-lite = { path = "crates/opentui-lite" } blake3 = "1" crypto_secretbox = "0.1" rand = "0.10" x25519-dalek = { version = "2", features = ["static_secrets"] } simd-json = { version = "0.17", optional = true } seq_everruns_bridge = { path = "crates/seq_everruns_bridge" } flow_commit_scan = { path = "crates/flow_commit_scan" } [target.'cfg(unix)'.dependencies] libc = "0.2" [dev-dependencies] mockito = "1.7" tempfile = "3" [features] default = [] linux-host-simd-json = ["dep:simd-json"] [profile.release] opt-level = 3 lto = "fat" codegen-units = 1 panic = "abort" strip = "symbols" incremental = false debug = 0 [patch.crates-io] axum = { path = "lib/vendor/axum" } reqwest = { path = "lib/vendor/reqwest" } tower-http = { path = "lib/vendor/tower-http" } ratatui = { path = "lib/vendor/ratatui" } url = { path = "lib/vendor/url" } crypto_secretbox = { path = "lib/vendor/crypto_secretbox" } portable-pty = { path = "lib/vendor/portable-pty" } tokio-stream = { path = "lib/vendor/tokio-stream" } tracing-subscriber = { path = "lib/vendor/tracing-subscriber" } futures = { path = "lib/vendor/futures" } sha1 = { path = "lib/vendor/sha1" } sha2 = { path = "lib/vendor/sha2" } tokio = { path = "lib/vendor/tokio" } crossterm = { path = "lib/vendor/crossterm" } hmac = { path = "lib/vendor/hmac" } toml = { path = "lib/vendor/toml" } clap = { path = "lib/vendor/clap" } notify-debouncer-mini = { path = "lib/vendor/notify-debouncer-mini" } ignore = { path = "lib/vendor/ignore" } x25519-dalek = { path = "lib/vendor/x25519-dalek" } rusqlite = { path = "lib/vendor/rusqlite" } rmp-serde = { path = "lib/vendor/rmp-serde" } ctrlc = { path = "lib/vendor/ctrlc" } notify = { path = "lib/vendor/notify" } regex = { path = "lib/vendor/regex" } serde = { path = "lib/vendor/serde" } ================================================ FILE: Support/flow/auth.toml ================================================ ================================================ FILE: agents.md ================================================ # Assistant Rules (flow) Only load skills when the request clearly needs them. ## Skills (on-demand) - flow-native: Use for Flow CLI native workflows (env setup, secrets, deploys, logs, deps), for repos with `flow.toml`, or Cloudflare Workers. Avoid direct pnpm/wrangler unless asked. - flow-interactive: Use when commands are interactive or could block on stdin (e.g., `f setup`). - flow-dev-traces: Use when debugging Flow proxy behavior, tracing requests, or when the user asks about proxyx, trace-summary.json, or flow trace commands. - flow-usage: Use when running or troubleshooting Flow command behavior. - internal-ai-inference: Use only when asked to run inference or integrate with internal AI tooling. Default: Avoid loading skills for routine edits, reviews, or simple questions. ================================================ FILE: bench/ffi_host_boundary/Cargo.toml ================================================ [package] name = "flow_ffi_host_boundary" version = "0.1.0" edition = "2024" [lib] crate-type = ["staticlib", "rlib"] [[bin]] name = "rust_boundary_bench" path = "src/bin/rust_boundary_bench.rs" [dependencies] libc = "0.2" [profile.release] lto = "fat" codegen-units = 1 panic = "abort" strip = true ================================================ FILE: bench/ffi_host_boundary/src/bin/rust_boundary_bench.rs ================================================ use std::hint::black_box; use flow_ffi_host_boundary::{flow_host_add_u64, flow_host_noop, monotonic_now_ns, rust_fn_add, rust_inline_add}; #[derive(Debug)] struct BenchResult { label: &'static str, ns_total: u64, ns_per_op: f64, checksum: u64, } fn finish(label: &'static str, iterations: u64, start: u64, acc: u64) -> BenchResult { let end = monotonic_now_ns(); let total = end.saturating_sub(start); BenchResult { label, ns_total: total, ns_per_op: total as f64 / iterations as f64, checksum: acc, } } fn bench_inline_add(iterations: u64) -> BenchResult { let mut acc = black_box(0_u64); let start = monotonic_now_ns(); for i in 0..iterations { acc = black_box(rust_inline_add(black_box(acc), black_box(i))); } finish("rust_inline_add", iterations, start, acc) } fn bench_fn_add(iterations: u64) -> BenchResult { let mut acc = black_box(0_u64); let start = monotonic_now_ns(); for i in 0..iterations { acc = black_box(rust_fn_add(black_box(acc), black_box(i))); } finish("rust_fn_add", iterations, start, acc) } fn bench_extern_add(iterations: u64) -> BenchResult { let mut acc = black_box(0_u64); let start = monotonic_now_ns(); for i in 0..iterations { acc = black_box(flow_host_add_u64(black_box(acc), black_box(i))); } finish("rust_extern_add", iterations, start, acc) } fn bench_noop(iterations: u64) -> BenchResult { let mut acc = black_box(0_u64); let start = monotonic_now_ns(); for _ in 0..iterations { acc = black_box(flow_host_noop(black_box(acc))); } finish("rust_extern_noop", iterations, start, acc) } fn parse_iters() -> u64 { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { if arg == "--iters" { if let Some(value) = args.next() { if let Ok(parsed) = value.parse::<u64>() { if parsed > 0 { return parsed; } } } } } 10_000_000 } fn print_result(result: &BenchResult) { println!( "{} ns_total={} ns_per_op={:.4} checksum={}", result.label, result.ns_total, result.ns_per_op, result.checksum ); } fn main() { let iterations = parse_iters(); println!("rust_boundary_bench iterations={}", iterations); let inline = bench_inline_add(iterations); let fn_call = bench_fn_add(iterations); let extern_call = bench_extern_add(iterations); let noop = bench_noop(iterations); print_result(&inline); print_result(&fn_call); print_result(&extern_call); print_result(&noop); } ================================================ FILE: bench/ffi_host_boundary/src/lib.rs ================================================ use std::hint::black_box; #[unsafe(no_mangle)] #[inline(never)] pub extern "C" fn flow_host_now_ns() -> u64 { monotonic_now_ns() } #[unsafe(no_mangle)] #[inline(never)] pub extern "C" fn flow_host_noop(x: u64) -> u64 { x.wrapping_add(1) } #[unsafe(no_mangle)] #[inline(never)] pub extern "C" fn flow_host_add_u64(a: u64, b: u64) -> u64 { a.wrapping_add(b) } #[unsafe(no_mangle)] pub extern "C" fn flow_host_bench_iterations() -> u64 { std::env::var("FLOW_FFI_ITERS") .ok() .and_then(|v| v.parse::<u64>().ok()) .filter(|v| *v > 0) .unwrap_or(10_000_000) } #[inline(always)] pub fn rust_inline_add(a: u64, b: u64) -> u64 { a.wrapping_add(b) } #[inline(never)] pub fn rust_fn_add(a: u64, b: u64) -> u64 { a.wrapping_add(b) } pub fn monotonic_now_ns() -> u64 { unsafe { let mut ts: libc::timespec = std::mem::zeroed(); if libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) != 0 { return 0; } (ts.tv_sec as u64) .saturating_mul(1_000_000_000) .saturating_add(ts.tv_nsec as u64) } } ================================================ FILE: bench/moon_ffi_boundary/main.mbt ================================================ extern "C" fn flow_host_now_ns() -> UInt64 = "flow_host_now_ns" extern "C" fn flow_host_noop(x : UInt64) -> UInt64 = "flow_host_noop" extern "C" fn flow_host_add_u64(a : UInt64, b : UInt64) -> UInt64 = "flow_host_add_u64" extern "C" fn flow_host_bench_iterations() -> UInt64 = "flow_host_bench_iterations" fn bench_ffi_add(iterations : Int) -> (UInt64, UInt64) { let mut acc : UInt64 = 0UL let start = flow_host_now_ns() for i = 0; i < iterations; i = i + 1 { acc = flow_host_add_u64(acc, i.to_uint64()) } let elapsed = flow_host_now_ns() - start (elapsed, acc) } fn bench_ffi_noop(iterations : Int) -> (UInt64, UInt64) { let mut acc : UInt64 = 0UL let start = flow_host_now_ns() for _i = 0; _i < iterations; _i = _i + 1 { acc = flow_host_noop(acc) } let elapsed = flow_host_now_ns() - start (elapsed, acc) } fn bench_moon_add(iterations : Int) -> (UInt64, UInt64) { let mut acc : UInt64 = 0UL let start = flow_host_now_ns() for i = 0; i < iterations; i = i + 1 { acc = acc + i.to_uint64() } let elapsed = flow_host_now_ns() - start (elapsed, acc) } fn parse_iterations() -> Int { flow_host_bench_iterations().to_int() } fn print_result( label : String, total_ns : UInt64, checksum : UInt64, iterations : Int, ) -> Unit { let per_op = total_ns / iterations.to_uint64() println( label + " ns_total=" + total_ns.to_string() + " ns_per_op=" + per_op.to_string() + " checksum=" + checksum.to_string(), ) } fn main { let iterations = parse_iterations() println("moon_ffi_boundary iterations=" + iterations.to_string()) let (moon_add_ns, moon_add_sum) = bench_moon_add(iterations) let (ffi_add_ns, ffi_add_sum) = bench_ffi_add(iterations) let (ffi_noop_ns, ffi_noop_sum) = bench_ffi_noop(iterations) print_result("moon_add", moon_add_ns, moon_add_sum, iterations) print_result("moon_ffi_add", ffi_add_ns, ffi_add_sum, iterations) print_result("moon_ffi_noop", ffi_noop_ns, ffi_noop_sum, iterations) } ================================================ FILE: bench/moon_ffi_boundary/moon.mod.json ================================================ { "name": "nikiv/moon_ffi_boundary", "version": "0.1.0" } ================================================ FILE: bench/moon_ffi_boundary/moon.pkg.template.json ================================================ { "is-main": true, "support-targets": ["native"], "link": { "native": { "cc-flags": "__CC_FLAGS__", "cc-link-flags": "__CC_LINK_FLAGS__" } } } ================================================ FILE: build.rs ================================================ use std::env; use std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; fn main() { // Embed build timestamp as seconds since Unix epoch let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); // Write timestamp to a file in OUT_DIR so cargo detects the change let out_dir = env::var("OUT_DIR").unwrap(); let dest_path = Path::new(&out_dir).join("build_timestamp.txt"); fs::write(&dest_path, timestamp.to_string()).unwrap(); println!("cargo:rustc-env=BUILD_TIMESTAMP={}", timestamp); // Always rerun build script (no rerun-if-changed means always run) } ================================================ FILE: crates/ai_taskd_client/Cargo.toml ================================================ [package] name = "ai-taskd-client" version = "0.1.0" edition = "2024" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" rmp-serde = "1" shell-words = "1" dirs = "6.0.0" ================================================ FILE: crates/ai_taskd_client/src/main.rs ================================================ include!("../../../src/bin/ai_taskd_client.rs"); ================================================ FILE: crates/flow_commit_scan/Cargo.toml ================================================ [package] name = "flow_commit_scan" version = "0.1.0" edition = "2024" [dependencies] regex = "1.12.2" ================================================ FILE: crates/flow_commit_scan/src/lib.rs ================================================ use std::path::Path; use std::process::Command; use std::sync::OnceLock; use regex::Regex; pub type SecretFinding = (String, usize, String, String); /// Common secret patterns to detect in diff content. /// Each tuple is (pattern_name, regex_pattern). const SECRET_PATTERNS: &[(&str, &str)] = &[ // API Keys with known prefixes ("AWS Access Key", r"AKIA[0-9A-Z]{16}"), ( "AWS Secret Key", r#"(?i)aws.{0,20}secret.{0,20}['"][0-9a-zA-Z/+]{40}['"]"#, ), ("GitHub Token", r"ghp_[0-9a-zA-Z]{36}"), ("GitHub OAuth", r"gho_[0-9a-zA-Z]{36}"), ("GitHub App Token", r"ghu_[0-9a-zA-Z]{36}"), ("GitHub Refresh Token", r"ghr_[0-9a-zA-Z]{36}"), ("GitLab Token", r"glpat-[0-9a-zA-Z\\-_]{20,}"), ("Slack Token", r"xox[baprs]-[0-9a-zA-Z]{10,48}"), ( "Slack Webhook", r"https://hooks\.slack\.com/services/T[0-9A-Z]{8,}/B[0-9A-Z]{8,}/[0-9a-zA-Z]{24}", ), ( "Discord Webhook", r"https://discord(?:app)?\.com/api/webhooks/[0-9]{17,}/[0-9a-zA-Z_-]{60,}", ), ("Stripe Key", r"sk_live_[0-9a-zA-Z]{24,}"), ("Stripe Restricted", r"rk_live_[0-9a-zA-Z]{24,}"), // OpenAI keys - multiple formats (legacy, project, service account) ("OpenAI Key (Legacy)", r"sk-[a-zA-Z0-9]{32,}"), ("OpenAI Key (Project)", r"sk-proj-[a-zA-Z0-9\\-_]{20,}"), ("OpenAI Key (Service)", r"sk-svcacct-[a-zA-Z0-9\\-_]{20,}"), ("Anthropic Key", r"sk-ant-[0-9a-zA-Z\\-_]{90,}"), ("Google API Key", r"AIza[0-9A-Za-z\\-_]{35}"), ("Groq API Key", r"gsk_[0-9a-zA-Z]{50,}"), ( "Mistral API Key", r#"(?i)mistral.{0,10}(api[_-]?key|key).{0,5}[=:].{0,5}["'][0-9a-zA-Z]{32,}["']"#, ), ( "Cohere API Key", r#"(?i)cohere.{0,10}(api[_-]?key|key).{0,5}[=:].{0,5}["'][0-9a-zA-Z]{40,}["']"#, ), ( "Heroku API Key", r"(?i)heroku.{0,20}[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", ), ("NPM Token", r"npm_[0-9a-zA-Z]{36}"), ("PyPI Token", r"pypi-[0-9a-zA-Z_-]{50,}"), ("Telegram Bot Token", r"[0-9]{8,10}:[0-9A-Za-z_-]{35}"), ("Twilio Key", r"SK[0-9a-fA-F]{32}"), ("SendGrid Key", r"SG\.[0-9a-zA-Z_-]{22}\.[0-9a-zA-Z_-]{43}"), ("Mailgun Key", r"key-[0-9a-zA-Z]{32}"), ( "Private Key", r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----", ), ( "Supabase Key", r"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[0-9a-zA-Z_-]{50,}", ), ( "Firebase Key", r#"(?i)firebase.{0,20}["'][A-Za-z0-9_-]{30,}["']"#, ), // Generic patterns (higher false positive risk, but catch common mistakes) ( "Generic API Key Assignment", r#"(?i)(api[_-]?key|apikey)\s*[:=]\s*['"][0-9a-zA-Z\-_]{20,}['"]"#, ), ( "Generic Secret Assignment", r#"(?i)(secret|password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]"#, ), ("Bearer Token", r"(?i)bearer\s+[0-9a-zA-Z\-_.]{20,}"), ("Basic Auth", r"(?i)basic\s+[A-Za-z0-9+/=]{20,}"), // High-entropy strings that look like secrets (env var assignments) ( "Env Var Secret", r#"(?i)(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[_A-Z]*\s*=\s*['"]?[0-9a-zA-Z\-_/+=]{32,}['"]?"#, ), ]; fn compiled_secret_patterns() -> &'static Vec<(&'static str, Regex)> { static COMPILED: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new(); COMPILED.get_or_init(|| { SECRET_PATTERNS .iter() .filter_map(|(name, pattern)| Regex::new(pattern).ok().map(|re| (*name, re))) .collect() }) } const SECRET_SCAN_IGNORE_MARKERS: &[&str] = &[ "flow:secret:ignore", "flow-secret-ignore", "flow:secret-scan:ignore", "gitleaks:allow", ]; fn should_ignore_secret_scan_line(content: &str) -> bool { let lower = content.to_lowercase(); SECRET_SCAN_IGNORE_MARKERS .iter() .any(|m| lower.contains(&m.to_lowercase())) } fn extract_first_quoted_value(s: &str) -> Option<&str> { let (qpos, qch) = s.char_indices().find(|(_, c)| *c == '"' || *c == '\'')?; let end = s.rfind(qch)?; if end <= qpos { return None; } Some(&s[qpos + 1..end]) } fn looks_like_identifier_reference(value: &str) -> bool { let v = value.trim(); !v.is_empty() && v.len() >= 8 && v.contains('_') && v.chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_' || c == '.') } fn looks_like_secret_lookup(value: &str) -> bool { let v = value.trim(); if v.starts_with("${") && v.ends_with('}') { let inner = &v[2..v.len() - 1]; return !inner.contains(":-") && !inner.contains("-") && inner .chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'); } if !(v.starts_with("$(") && v.ends_with(')')) { return false; } let inner = v[2..v.len() - 1].trim(); if inner.contains('"') || inner.contains('\'') || inner.contains('`') { return false; } let inner_lc = inner.to_lowercase(); inner_lc.starts_with("get_env ") || inner_lc.starts_with("getenv ") || inner_lc.starts_with("printenv ") || inner_lc.starts_with("op read ") || inner_lc.starts_with("pass show ") || inner_lc.starts_with("security find-generic-password") || inner_lc.starts_with("aws ssm get-parameter") || inner_lc.starts_with("vault kv get") || inner_lc.starts_with("bw get") || inner_lc.starts_with("gcloud secrets versions access") } fn generic_secret_assignment_is_false_positive(content: &str, matched: &str) -> bool { if let Some((_, rhs)) = matched.split_once('=') { let rhs = rhs.trim_start(); if rhs.starts_with("\"$(") || rhs.starts_with("'$(") || rhs.starts_with("`") { return true; } if rhs.starts_with("\"$") || rhs.starts_with("'$") { return true; } } else if let Some((_, rhs)) = matched.split_once(':') { let rhs = rhs.trim_start(); if rhs.starts_with("\"$(") || rhs.starts_with("'$(") || rhs.starts_with("`") { return true; } if rhs.starts_with("\"$") || rhs.starts_with("'$") { return true; } } if let Some(val) = extract_first_quoted_value(matched) { let v = val.trim(); if looks_like_identifier_reference(v) { return true; } if looks_like_secret_lookup(v) { return true; } } let lc = content.to_lowercase(); lc.contains("$(get_env ") } /// Scan staged diff content for hardcoded secrets. /// Returns list of (file, line_num, pattern_name, matched_text) for detected secrets. pub fn scan_diff_for_secrets(repo_root: &Path) -> Vec<SecretFinding> { let output = Command::new("git") .args(["diff", "--cached", "-U0"]) .current_dir(repo_root) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } let diff = String::from_utf8_lossy(&output.stdout); let mut findings: Vec<SecretFinding> = Vec::new(); let mut current_file = String::new(); let mut current_line: usize = 0; let mut ignore_next_added_line = false; let patterns = compiled_secret_patterns(); for line in diff.lines() { if line.starts_with("+++ b/") { current_file = line.strip_prefix("+++ b/").unwrap_or("").to_string(); ignore_next_added_line = false; continue; } if line.starts_with("@@") { if let Some(plus_pos) = line.find('+') { let after_plus = &line[plus_pos + 1..]; let num_str: String = after_plus .chars() .take_while(|c| c.is_ascii_digit()) .collect(); current_line = num_str.parse().unwrap_or(0); } ignore_next_added_line = false; continue; } if line.starts_with('+') && !line.starts_with("+++") { let content = &line[1..]; if ignore_next_added_line { ignore_next_added_line = false; current_line += 1; continue; } let trimmed = content.trim_start(); if trimmed.starts_with('#') && should_ignore_secret_scan_line(trimmed) { ignore_next_added_line = true; current_line += 1; continue; } if should_ignore_secret_scan_line(content) { current_line += 1; continue; } if content.to_lowercase().contains("flow:secret:ignore-next") { ignore_next_added_line = true; current_line += 1; continue; } for (name, re) in patterns { if let Some(m) = re.find(content) { let matched = m.as_str(); let matched_lower = matched.to_lowercase(); if matched_lower.contains("xxx") || matched_lower.contains("your") || matched_lower.contains("example") || matched_lower.contains("placeholder") || matched_lower.contains("replace") || matched_lower.contains("insert") || matched_lower.contains("todo") || matched_lower.contains("fixme") || matched == "sk-..." || matched == "sk-xxxx" || matched .chars() .all(|c| c == 'x' || c == 'X' || c == '.' || c == '-' || c == '_') { continue; } if *name == "Generic Secret Assignment" && generic_secret_assignment_is_false_positive(content, matched) { continue; } let redacted = if matched.len() > 12 { format!("{}...{}", &matched[..6], &matched[matched.len() - 4..]) } else { matched.to_string() }; findings.push(( current_file.clone(), current_line, name.to_string(), redacted, )); break; } } current_line += 1; } else if !line.starts_with('-') && !line.starts_with('\\') { current_line += 1; ignore_next_added_line = false; } } findings } ================================================ FILE: crates/opentui-lite/Cargo.toml ================================================ [package] name = "opentui-lite" version = "0.1.0" edition = "2024" [lib] path = "src/lib.rs" [dependencies] libc = { version = "0.2", default-features = false } ================================================ FILE: crates/opentui-lite/src/lib.rs ================================================ use std::ffi::{CStr, CString}; use std::fmt; use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Debug)] pub struct Error { message: String, } impl Error { fn new(message: impl Into<String>) -> Self { Self { message: message.into(), } } } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.message) } } impl std::error::Error for Error {} pub type Result<T> = std::result::Result<T, Error>; #[repr(C)] #[derive(Clone, Copy, Debug, Default)] pub struct Color { pub r: f32, pub g: f32, pub b: f32, pub a: f32, } impl Color { pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self { Self { r, g, b, a } } pub const fn rgb(r: f32, g: f32, b: f32) -> Self { Self { r, g, b, a: 1.0 } } } pub const ATTR_NONE: u32 = 0; pub const ATTR_BOLD: u32 = 1 << 0; pub const ATTR_DIM: u32 = 1 << 1; pub const ATTR_ITALIC: u32 = 1 << 2; pub const ATTR_UNDERLINE: u32 = 1 << 3; pub const ATTR_BLINK: u32 = 1 << 4; pub const ATTR_INVERSE: u32 = 1 << 5; pub const ATTR_HIDDEN: u32 = 1 << 6; pub const ATTR_STRIKETHROUGH: u32 = 1 << 7; pub const BORDER_SIMPLE: [u32; 11] = [ '+' as u32, '+' as u32, '+' as u32, '+' as u32, '-' as u32, '|' as u32, '+' as u32, '+' as u32, '+' as u32, '+' as u32, '+' as u32, ]; type RendererPtr = *mut std::ffi::c_void; type BufferPtr = *mut std::ffi::c_void; type FnCreateRenderer = unsafe extern "C" fn(u32, u32, bool) -> RendererPtr; type FnDestroyRenderer = unsafe extern "C" fn(RendererPtr); type FnSetupTerminal = unsafe extern "C" fn(RendererPtr, bool); type FnSuspendRenderer = unsafe extern "C" fn(RendererPtr); type FnRender = unsafe extern "C" fn(RendererPtr, bool); type FnClearTerminal = unsafe extern "C" fn(RendererPtr); type FnResizeRenderer = unsafe extern "C" fn(RendererPtr, u32, u32); type FnGetNextBuffer = unsafe extern "C" fn(RendererPtr) -> BufferPtr; type FnGetCurrentBuffer = unsafe extern "C" fn(RendererPtr) -> BufferPtr; type FnBufferClear = unsafe extern "C" fn(BufferPtr, *const f32); type FnBufferDrawText = unsafe extern "C" fn(BufferPtr, *const u8, usize, u32, u32, *const f32, *const f32, u32); type FnBufferFillRect = unsafe extern "C" fn(BufferPtr, u32, u32, u32, u32, *const f32); type FnBufferDrawBox = unsafe extern "C" fn( BufferPtr, i32, i32, u32, u32, *const u32, u32, *const f32, *const f32, *const u8, u32, ); #[derive(Clone)] pub struct OpenTui { inner: Arc<Inner>, } struct Inner { lib: *mut std::ffi::c_void, fns: Fns, path: String, } struct Fns { create_renderer: FnCreateRenderer, destroy_renderer: FnDestroyRenderer, setup_terminal: FnSetupTerminal, suspend_renderer: FnSuspendRenderer, render: FnRender, clear_terminal: FnClearTerminal, resize_renderer: FnResizeRenderer, get_next_buffer: FnGetNextBuffer, get_current_buffer: FnGetCurrentBuffer, buffer_clear: FnBufferClear, buffer_draw_text: FnBufferDrawText, buffer_fill_rect: FnBufferFillRect, buffer_draw_box: FnBufferDrawBox, } impl Drop for Inner { fn drop(&mut self) { unsafe { if !self.lib.is_null() { let _ = dlclose(self.lib); } } } } impl OpenTui { pub fn load() -> Result<Self> { let (lib, path) = load_library()?; let fns = unsafe { Fns { create_renderer: load_symbol(lib, "createRenderer")?, destroy_renderer: load_symbol(lib, "destroyRenderer")?, setup_terminal: load_symbol(lib, "setupTerminal")?, suspend_renderer: load_symbol(lib, "suspendRenderer")?, render: load_symbol(lib, "render")?, clear_terminal: load_symbol(lib, "clearTerminal")?, resize_renderer: load_symbol(lib, "resizeRenderer")?, get_next_buffer: load_symbol(lib, "getNextBuffer")?, get_current_buffer: load_symbol(lib, "getCurrentBuffer")?, buffer_clear: load_symbol(lib, "bufferClear")?, buffer_draw_text: load_symbol(lib, "bufferDrawText")?, buffer_fill_rect: load_symbol(lib, "bufferFillRect")?, buffer_draw_box: load_symbol(lib, "bufferDrawBox")?, } }; Ok(Self { inner: Arc::new(Inner { lib, fns, path }), }) } pub fn path(&self) -> &str { &self.inner.path } pub fn create_renderer(&self, width: u32, height: u32, testing: bool) -> Result<Renderer> { let ptr = unsafe { (self.inner.fns.create_renderer)(width, height, testing) }; if ptr.is_null() { return Err(Error::new("opentui: createRenderer returned null")); } Ok(Renderer { inner: self.inner.clone(), ptr, }) } } pub struct Renderer { inner: Arc<Inner>, ptr: RendererPtr, } impl Renderer { pub fn setup_terminal(&self, use_alternate_screen: bool) { unsafe { (self.inner.fns.setup_terminal)(self.ptr, use_alternate_screen) }; } pub fn suspend(&self) { unsafe { (self.inner.fns.suspend_renderer)(self.ptr) }; } pub fn clear_terminal(&self) { unsafe { (self.inner.fns.clear_terminal)(self.ptr) }; } pub fn resize(&self, width: u32, height: u32) { unsafe { (self.inner.fns.resize_renderer)(self.ptr, width, height) }; } pub fn render(&self, force: bool) { unsafe { (self.inner.fns.render)(self.ptr, force) }; } pub fn next_buffer(&self) -> Buffer { let ptr = unsafe { (self.inner.fns.get_next_buffer)(self.ptr) }; Buffer { inner: self.inner.clone(), ptr, } } pub fn current_buffer(&self) -> Buffer { let ptr = unsafe { (self.inner.fns.get_current_buffer)(self.ptr) }; Buffer { inner: self.inner.clone(), ptr, } } } impl Drop for Renderer { fn drop(&mut self) { unsafe { (self.inner.fns.destroy_renderer)(self.ptr); } } } pub struct Buffer { inner: Arc<Inner>, ptr: BufferPtr, } impl Buffer { pub fn clear(&self, bg: Color) { unsafe { (self.inner.fns.buffer_clear)(self.ptr, &bg as *const Color as *const f32) }; } pub fn fill_rect(&self, x: u32, y: u32, width: u32, height: u32, bg: Color) { unsafe { (self.inner.fns.buffer_fill_rect)( self.ptr, x, y, width, height, &bg as *const Color as *const f32, ) }; } pub fn draw_text(&self, text: &str, x: u32, y: u32, fg: Color, bg: Option<Color>, attr: u32) { let bg_ptr = match bg { Some(color) => &color as *const Color as *const f32, None => std::ptr::null(), }; unsafe { (self.inner.fns.buffer_draw_text)( self.ptr, text.as_ptr(), text.len(), x, y, &fg as *const Color as *const f32, bg_ptr, attr, ) }; } pub fn draw_box( &self, x: i32, y: i32, width: u32, height: u32, border_chars: &[u32; 11], packed_options: u32, border: Color, background: Color, title: Option<&str>, ) { let (title_ptr, title_len) = match title { Some(value) => (value.as_ptr(), value.len() as u32), None => (std::ptr::null(), 0), }; unsafe { (self.inner.fns.buffer_draw_box)( self.ptr, x, y, width, height, border_chars.as_ptr(), packed_options, &border as *const Color as *const f32, &background as *const Color as *const f32, title_ptr, title_len, ) }; } } fn load_library() -> Result<(*mut std::ffi::c_void, String)> { let mut errors = Vec::new(); for path in candidate_paths() { match try_dlopen(&path) { Ok(lib) => return Ok((lib, path.display().to_string())), Err(err) => errors.push(format!("{}: {}", path.display(), err)), } } let mut message = String::from("opentui: failed to load native library"); if !errors.is_empty() { message.push_str(" (tried: "); message.push_str(&errors.join(", ")); message.push(')'); } Err(Error::new(message)) } fn candidate_paths() -> Vec<PathBuf> { let mut paths = Vec::new(); let lib_name = lib_filename(); if let Ok(path) = std::env::var("OPENTUI_LIB_PATH") { paths.push(PathBuf::from(path)); } if let Ok(dir) = std::env::var("OPENTUI_LIB_DIR") { paths.push(PathBuf::from(dir).join(lib_name)); } if let Ok(prefix) = std::env::var("OPENTUI_PREFIX") { paths.push(PathBuf::from(prefix).join("lib").join(lib_name)); } if let Ok(home) = std::env::var("HOME") { let home_path = PathBuf::from(&home); if let Some(target_dir) = zig_target_dir() { paths.push( home_path .join("repos/anomalyco/opentui/packages/core/src/zig/lib") .join(target_dir) .join(lib_name), ); } paths.push(home_path.join(".local/lib").join(lib_name)); } paths.push(PathBuf::from(lib_name)); paths } fn zig_target_dir() -> Option<&'static str> { match (std::env::consts::ARCH, std::env::consts::OS) { ("aarch64", "macos") => Some("aarch64-macos"), ("x86_64", "macos") => Some("x86_64-macos"), ("aarch64", "linux") => Some("aarch64-linux"), ("x86_64", "linux") => Some("x86_64-linux"), _ => None, } } fn lib_filename() -> &'static str { if cfg!(target_os = "macos") { "libopentui.dylib" } else if cfg!(target_os = "linux") { "libopentui.so" } else { "libopentui" } } fn try_dlopen(path: &Path) -> Result<*mut std::ffi::c_void> { let cpath = path_to_cstring(path)?; unsafe { let handle = dlopen(cpath.as_ptr(), libc::RTLD_NOW); if handle.is_null() { return Err(Error::new(dl_error_string())); } Ok(handle) } } fn path_to_cstring(path: &Path) -> Result<CString> { #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; CString::new(path.as_os_str().as_bytes()) .map_err(|_| Error::new("opentui: invalid library path")) } #[cfg(not(unix))] { Err(Error::new("opentui: unsupported platform")) } } unsafe fn load_symbol<T>(lib: *mut std::ffi::c_void, symbol: &str) -> Result<T> { let name = CString::new(symbol).map_err(|_| Error::new("opentui: invalid symbol"))?; let ptr = unsafe { dlsym(lib, name.as_ptr()) }; if ptr.is_null() { return Err(Error::new(format!("opentui: missing symbol {symbol}"))); } Ok(unsafe { std::mem::transmute_copy(&ptr) }) } fn dl_error_string() -> String { unsafe { let err = dlerror(); if err.is_null() { return "unknown dlopen error".to_string(); } CStr::from_ptr(err).to_string_lossy().to_string() } } unsafe extern "C" { fn dlopen(path: *const libc::c_char, mode: libc::c_int) -> *mut std::ffi::c_void; fn dlsym(handle: *mut std::ffi::c_void, symbol: *const libc::c_char) -> *mut std::ffi::c_void; fn dlclose(handle: *mut std::ffi::c_void) -> libc::c_int; fn dlerror() -> *const libc::c_char; } ================================================ FILE: crates/seq_client/Cargo.toml ================================================ [package] name = "seq_client" version = "0.1.0" edition = "2021" description = "Rust client for seqd Agent RPC v1 over Unix sockets" license = "MIT" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" ================================================ FILE: crates/seq_client/src/lib.rs ================================================ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; use thiserror::Error; const DEFAULT_SOCKET_PATH: &str = "/tmp/seqd.sock"; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; #[derive(Debug, Error)] pub enum SeqClientError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("json error: {0}")] Json(#[from] serde_json::Error), #[error("invalid protocol: {0}")] Protocol(String), #[error("remote error: {0}")] Remote(String), } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RpcRequest { pub op: String, #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub run_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub args: Option<Value>, } impl RpcRequest { pub fn new(op: impl Into<String>) -> Self { Self { op: op.into(), ..Self::default() } } pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self { self.request_id = Some(request_id.into()); self } pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self { self.run_id = Some(run_id.into()); self } pub fn with_tool_call_id(mut self, tool_call_id: impl Into<String>) -> Self { self.tool_call_id = Some(tool_call_id.into()); self } pub fn with_args_json(mut self, args: Value) -> Self { self.args = Some(args); self } pub fn with_args<T: Serialize>(mut self, args: &T) -> Result<Self, SeqClientError> { self.args = Some(serde_json::to_value(args)?); Ok(self) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RpcResponse { pub ok: bool, pub op: String, #[serde(default)] pub request_id: String, #[serde(default)] pub run_id: String, #[serde(default)] pub tool_call_id: String, pub ts_ms: u64, pub dur_us: u64, #[serde(default)] pub result: Option<Value>, #[serde(default)] pub error: Option<String>, } #[derive(Debug)] pub struct SeqClient { socket_path: PathBuf, stream: Mutex<UnixStream>, } impl SeqClient { pub fn connect_default() -> Result<Self, SeqClientError> { Self::connect(DEFAULT_SOCKET_PATH) } pub fn connect(path: impl AsRef<Path>) -> Result<Self, SeqClientError> { let stream = UnixStream::connect(path.as_ref())?; Ok(Self { socket_path: path.as_ref().to_path_buf(), stream: Mutex::new(stream), }) } pub fn connect_with_timeout( path: impl AsRef<Path>, timeout: Duration, ) -> Result<Self, SeqClientError> { let stream = UnixStream::connect(path.as_ref())?; stream.set_read_timeout(Some(timeout))?; stream.set_write_timeout(Some(timeout))?; Ok(Self { socket_path: path.as_ref().to_path_buf(), stream: Mutex::new(stream), }) } pub fn socket_path(&self) -> &Path { &self.socket_path } pub fn call(&self, request: RpcRequest) -> Result<RpcResponse, SeqClientError> { let mut stream = self .stream .lock() .map_err(|_| SeqClientError::Protocol("socket mutex poisoned".into()))?; write_request(&mut stream, &request)?; let line = read_response_line(&mut stream)?; let response: RpcResponse = serde_json::from_slice(&line)?; Ok(response) } pub fn call_ok(&self, request: RpcRequest) -> Result<Value, SeqClientError> { let response = self.call(request)?; if response.ok { Ok(response.result.unwrap_or_else(|| json!({}))) } else { Err(SeqClientError::Remote( response .error .unwrap_or_else(|| "unknown_error".to_string()), )) } } pub fn ping(&self) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("ping")) } pub fn app_state(&self) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("app_state")) } pub fn perf(&self) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("perf")) } pub fn open_app(&self, name: &str) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("open_app").with_args_json(json!({ "name": name }))) } pub fn open_app_toggle(&self, name: &str) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("open_app_toggle").with_args_json(json!({ "name": name }))) } pub fn run_macro(&self, name: &str) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("run_macro").with_args_json(json!({ "name": name }))) } pub fn click(&self, x: f64, y: f64) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("click").with_args_json(json!({ "x": x, "y": y }))) } pub fn right_click(&self, x: f64, y: f64) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("right_click").with_args_json(json!({ "x": x, "y": y }))) } pub fn double_click(&self, x: f64, y: f64) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("double_click").with_args_json(json!({ "x": x, "y": y }))) } pub fn move_mouse(&self, x: f64, y: f64) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("move").with_args_json(json!({ "x": x, "y": y }))) } pub fn scroll(&self, x: f64, y: f64, dy: i32) -> Result<RpcResponse, SeqClientError> { self.call(RpcRequest::new("scroll").with_args_json(json!({ "x": x, "y": y, "dy": dy }))) } pub fn drag(&self, x1: f64, y1: f64, x2: f64, y2: f64) -> Result<RpcResponse, SeqClientError> { self.call( RpcRequest::new("drag") .with_args_json(json!({ "x1": x1, "y1": y1, "x2": x2, "y2": y2 })), ) } pub fn screenshot(&self, path: Option<&str>) -> Result<RpcResponse, SeqClientError> { let req = if let Some(path) = path { RpcRequest::new("screenshot").with_args_json(json!({ "path": path })) } else { RpcRequest::new("screenshot") }; self.call(req) } } fn write_request(stream: &mut UnixStream, request: &RpcRequest) -> Result<(), SeqClientError> { let mut payload = serde_json::to_vec(request)?; payload.push(b'\n'); stream.write_all(&payload)?; Ok(()) } fn read_response_line(stream: &mut UnixStream) -> Result<Vec<u8>, SeqClientError> { let mut out = Vec::with_capacity(512); let mut buf = [0u8; 512]; loop { let n = stream.read(&mut buf)?; if n == 0 { if out.is_empty() { return Err(SeqClientError::Protocol( "unexpected EOF while waiting for response".to_string(), )); } break; } for b in &buf[..n] { out.push(*b); if *b == b'\n' { out.pop(); return Ok(out); } } if out.len() > MAX_RESPONSE_BYTES { return Err(SeqClientError::Protocol( "response exceeded max size".to_string(), )); } } Ok(out) } #[cfg(test)] mod tests { use super::*; use std::fs; use std::io::{BufRead, BufReader}; use std::os::unix::net::UnixListener; use std::thread; fn test_socket_path(tag: &str) -> PathBuf { let mut p = std::env::temp_dir(); let pid = std::process::id(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") .as_nanos(); p.push(format!("seq_client_{tag}_{pid}_{now}.sock")); p } #[test] fn call_roundtrip_ping() { let path = test_socket_path("ping"); let listener = UnixListener::bind(&path).expect("bind"); let server = thread::spawn(move || { let (stream, _) = listener.accept().expect("accept"); let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line).expect("read line"); let req: Value = serde_json::from_str(line.trim()).expect("parse req"); assert_eq!(req["op"], "ping"); let response = json!({ "ok": true, "op": "ping", "request_id": "", "run_id": "", "tool_call_id": "", "ts_ms": 1, "dur_us": 2, "result": { "pong": true } }); let mut inner = reader.into_inner(); inner .write_all(format!("{}\n", response).as_bytes()) .expect("write"); }); let client = SeqClient::connect(&path).expect("connect"); let response = client.ping().expect("call"); assert!(response.ok); assert_eq!(response.op, "ping"); assert_eq!(response.result.unwrap()["pong"], true); server.join().expect("join"); let _ = fs::remove_file(path); } #[test] fn call_ok_surfaces_remote_error() { let path = test_socket_path("err"); let listener = UnixListener::bind(&path).expect("bind"); let server = thread::spawn(move || { let (stream, _) = listener.accept().expect("accept"); let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line).expect("read line"); let response = json!({ "ok": false, "op": "open_app", "request_id": "r1", "run_id": "", "tool_call_id": "", "ts_ms": 10, "dur_us": 11, "error": "missing_name" }); let mut inner = reader.into_inner(); inner .write_all(format!("{}\n", response).as_bytes()) .expect("write"); }); let client = SeqClient::connect(&path).expect("connect"); let err = client .call_ok(RpcRequest::new("open_app")) .expect_err("should fail"); match err { SeqClientError::Remote(s) => assert_eq!(s, "missing_name"), other => panic!("unexpected error: {other:?}"), } server.join().expect("join"); let _ = fs::remove_file(path); } } ================================================ FILE: crates/seq_everruns_bridge/Cargo.toml ================================================ [package] name = "seq_everruns_bridge" version = "0.1.0" edition = "2021" description = "Everruns client-side tool bridge for seqd RPC" license = "MIT" [dependencies] seq_client = { path = "../seq_client" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } ================================================ FILE: crates/seq_everruns_bridge/src/lib.rs ================================================ use seq_client::{RpcRequest, SeqClient}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use thiserror::Error; pub mod maple; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { pub id: String, pub name: String, #[serde(default)] pub arguments: Value, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ToolResult { pub tool_call_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub result: Option<Value>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option<String>, } #[derive(Debug, Deserialize)] pub struct ToolCallRequestedData { pub tool_calls: Vec<ToolCall>, } #[derive(Debug, Error)] pub enum BridgeError { #[error("unsupported seq tool name: {0}")] UnsupportedTool(String), } pub fn parse_tool_call_requested(data: &Value) -> Result<Vec<ToolCall>, serde_json::Error> { let parsed: ToolCallRequestedData = serde_json::from_value(data.clone())?; Ok(parsed.tool_calls) } pub fn execute_tool_call( client: &SeqClient, session_id: &str, event_id: &str, call: &ToolCall, ) -> ToolResult { execute_tool_call_with_maple(client, session_id, event_id, call, None) } pub fn execute_tool_call_with_maple( client: &SeqClient, session_id: &str, event_id: &str, call: &ToolCall, maple_exporter: Option<&maple::MapleTraceExporter>, ) -> ToolResult { let started = Instant::now(); let start_unix_nano = unix_time_nanos_now(); let seq_op = map_tool_name_to_seq_op(&call.name).unwrap_or("unknown"); let result = match build_request(session_id, event_id, call) { Ok(req) => match client.call(req) { Ok(resp) => { if resp.ok { ToolResult { tool_call_id: call.id.clone(), result: Some(resp.result.unwrap_or_else(|| json!({}))), error: None, } } else { let op = map_tool_name_to_seq_op(&call.name).unwrap_or("unknown"); ToolResult { tool_call_id: call.id.clone(), result: None, error: Some( resp.error .unwrap_or_else(|| format!("seq {op} failed with unknown error")), ), } } } Err(err) => { let op = map_tool_name_to_seq_op(&call.name).unwrap_or("unknown"); ToolResult { tool_call_id: call.id.clone(), result: None, error: Some(format!("seq {op} call failed: {err}")), } } }, Err(err) => ToolResult { tool_call_id: call.id.clone(), result: None, error: Some(err.to_string()), }, }; if let Some(exporter) = maple_exporter { let elapsed = started.elapsed(); let duration_ms = elapsed.as_millis() as u64; let end_unix_nano = start_unix_nano.saturating_add(elapsed.as_nanos() as u64); let ok = result.error.is_none(); let span = maple::MapleSpan::for_tool_call( session_id, event_id, &call.id, &call.name, seq_op, ok, result.error.as_deref(), start_unix_nano, end_unix_nano, duration_ms, ); exporter.emit_span(span); } result } pub fn build_request( session_id: &str, event_id: &str, call: &ToolCall, ) -> Result<RpcRequest, BridgeError> { let op = map_tool_name_to_seq_op(&call.name) .ok_or_else(|| BridgeError::UnsupportedTool(call.name.clone()))?; let mut req = RpcRequest::new(op) .with_request_id(format!("everruns:{event_id}:{}", call.id)) .with_run_id(session_id) .with_tool_call_id(&call.id); if !call.arguments.is_null() { req = req.with_args_json(call.arguments.clone()); } Ok(req) } pub fn map_tool_name_to_seq_op(tool_name: &str) -> Option<&'static str> { let mut name = tool_name.trim().to_ascii_lowercase().replace('-', "_"); for prefix in ["seq.", "seq:", "seq_"] { if let Some(rest) = name.strip_prefix(prefix) { name = rest.to_string(); break; } } match name.as_str() { "ping" => Some("ping"), "app_state" => Some("app_state"), "perf" => Some("perf"), "open_app" => Some("open_app"), "open_app_toggle" => Some("open_app_toggle"), "run_macro" => Some("run_macro"), "click" => Some("click"), "right_click" => Some("right_click"), "double_click" => Some("double_click"), "move" => Some("move"), "scroll" => Some("scroll"), "drag" => Some("drag"), "screenshot" => Some("screenshot"), _ => None, } } pub fn client_side_tool_definitions() -> Vec<Value> { vec![ client_tool( "seq_ping", "Health check seqd runtime", json!({"type":"object","properties":{},"additionalProperties":false}), ), client_tool( "seq_app_state", "Get frontmost/previous app snapshot", json!({"type":"object","properties":{},"additionalProperties":false}), ), client_tool( "seq_perf", "Get seqd performance snapshot", json!({"type":"object","properties":{},"additionalProperties":false}), ), client_tool( "seq_open_app", "Open application by name", json!({ "type":"object", "properties":{"name":{"type":"string","description":"App name (e.g. Safari)"}}, "required":["name"], "additionalProperties":false }), ), client_tool( "seq_open_app_toggle", "Toggle to app by name", json!({ "type":"object", "properties":{"name":{"type":"string","description":"App name (e.g. Safari)"}}, "required":["name"], "additionalProperties":false }), ), client_tool( "seq_run_macro", "Run seq macro by name", json!({ "type":"object", "properties":{"name":{"type":"string","description":"Macro name"}}, "required":["name"], "additionalProperties":false }), ), client_tool( "seq_click", "Click at screen coordinates", json!({ "type":"object", "properties":{"x":{"type":"number"},"y":{"type":"number"}}, "required":["x","y"], "additionalProperties":false }), ), client_tool( "seq_right_click", "Right click at screen coordinates", json!({ "type":"object", "properties":{"x":{"type":"number"},"y":{"type":"number"}}, "required":["x","y"], "additionalProperties":false }), ), client_tool( "seq_double_click", "Double click at screen coordinates", json!({ "type":"object", "properties":{"x":{"type":"number"},"y":{"type":"number"}}, "required":["x","y"], "additionalProperties":false }), ), client_tool( "seq_move", "Move pointer to coordinates", json!({ "type":"object", "properties":{"x":{"type":"number"},"y":{"type":"number"}}, "required":["x","y"], "additionalProperties":false }), ), client_tool( "seq_scroll", "Scroll at coordinates by delta", json!({ "type":"object", "properties":{"x":{"type":"number"},"y":{"type":"number"},"dy":{"type":"integer"}}, "required":["x","y","dy"], "additionalProperties":false }), ), client_tool( "seq_drag", "Drag from one coordinate to another", json!({ "type":"object", "properties":{"x1":{"type":"number"},"y1":{"type":"number"},"x2":{"type":"number"},"y2":{"type":"number"}}, "required":["x1","y1","x2","y2"], "additionalProperties":false }), ), client_tool( "seq_screenshot", "Capture screenshot to optional path", json!({ "type":"object", "properties":{"path":{"type":"string","description":"Output path (optional)"}}, "additionalProperties":false }), ), ] } fn client_tool(name: &str, description: &str, parameters: Value) -> Value { json!({ "type": "client_side", "name": name, "description": description, "parameters": parameters }) } fn unix_time_nanos_now() -> u64 { match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(dur) => dur.as_nanos() as u64, Err(_) => 0, } } #[cfg(test)] mod tests { use super::*; #[test] fn maps_supported_tool_names() { assert_eq!(map_tool_name_to_seq_op("seq_open_app"), Some("open_app")); assert_eq!(map_tool_name_to_seq_op("seq.open_app"), Some("open_app")); assert_eq!(map_tool_name_to_seq_op("seq:open-app"), Some("open_app")); assert_eq!(map_tool_name_to_seq_op("PING"), Some("ping")); assert_eq!(map_tool_name_to_seq_op("unknown_tool"), None); } #[test] fn builds_request_with_correlation_ids() { let call = ToolCall { id: "tool-9".to_string(), name: "seq_click".to_string(), arguments: json!({"x": 1, "y": 2}), }; let req = build_request("session-1", "event-7", &call).expect("request should build"); assert_eq!(req.op, "click"); assert_eq!(req.request_id.as_deref(), Some("everruns:event-7:tool-9")); assert_eq!(req.run_id.as_deref(), Some("session-1")); assert_eq!(req.tool_call_id.as_deref(), Some("tool-9")); assert_eq!(req.args, Some(json!({"x": 1, "y": 2}))); } #[test] fn emits_expected_tool_catalog() { let defs = client_side_tool_definitions(); assert_eq!(defs.len(), 13); let names: Vec<&str> = defs .iter() .filter_map(|v| v.get("name").and_then(Value::as_str)) .collect(); assert!(names.contains(&"seq_open_app")); assert!(names.contains(&"seq_screenshot")); } #[test] fn parse_tool_call_requested_payload() { let payload = json!({ "tool_calls": [ {"id":"tc1","name":"seq_ping","arguments":{}}, {"id":"tc2","name":"seq_open_app","arguments":{"name":"Safari"}} ] }); let calls = parse_tool_call_requested(&payload).expect("payload should parse"); assert_eq!(calls.len(), 2); assert_eq!(calls[0].id, "tc1"); assert_eq!(calls[1].name, "seq_open_app"); } #[test] fn unsupported_tool_returns_error_result() { let call = ToolCall { id: "tcX".to_string(), name: "seq_not_real".to_string(), arguments: json!({}), }; let result = ToolResult { tool_call_id: call.id.clone(), result: None, error: Some( build_request("session", "event", &call) .expect_err("should error") .to_string(), ), }; assert_eq!(result.tool_call_id, "tcX"); assert!(result .error .unwrap_or_default() .contains("unsupported seq tool name")); } #[test] fn bridge_error_is_displayable() { let e = BridgeError::UnsupportedTool("foo".to_string()); assert_eq!(e.to_string(), "unsupported seq tool name: foo"); } } ================================================ FILE: crates/seq_everruns_bridge/src/maple.rs ================================================ use reqwest::blocking::Client; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TryRecvError}; use std::sync::Arc; use std::thread; use std::time::Duration; use thiserror::Error; const DEFAULT_SCOPE_NAME: &str = "seq_everruns_bridge"; const DEFAULT_SERVICE_NAME: &str = "seq-everruns-bridge"; const DEFAULT_ENV: &str = "local"; const DEFAULT_QUEUE_CAPACITY: usize = 4096; const DEFAULT_MAX_BATCH_SIZE: usize = 128; const DEFAULT_FLUSH_INTERVAL_MS: u64 = 50; const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 400; const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 800; #[derive(Debug, Clone)] pub struct MapleIngestTarget { pub traces_endpoint: String, pub ingest_key: String, } #[derive(Debug, Clone)] pub struct MapleExporterConfig { pub service_name: String, pub service_version: Option<String>, pub deployment_environment: String, pub scope_name: String, pub queue_capacity: usize, pub max_batch_size: usize, pub flush_interval: Duration, pub connect_timeout: Duration, pub request_timeout: Duration, pub targets: Vec<MapleIngestTarget>, } impl MapleExporterConfig { pub fn from_env() -> Result<Option<Self>, MapleConfigError> { let targets = parse_targets_from_env()?; if targets.is_empty() { return Ok(None); } let service_name = std::env::var("SEQ_EVERRUNS_MAPLE_SERVICE_NAME") .ok() .and_then(non_empty) .unwrap_or_else(|| DEFAULT_SERVICE_NAME.to_string()); let deployment_environment = std::env::var("SEQ_EVERRUNS_MAPLE_ENV") .ok() .and_then(non_empty) .unwrap_or_else(|| DEFAULT_ENV.to_string()); let service_version = std::env::var("SEQ_EVERRUNS_MAPLE_SERVICE_VERSION") .ok() .and_then(non_empty); let scope_name = std::env::var("SEQ_EVERRUNS_MAPLE_SCOPE_NAME") .ok() .and_then(non_empty) .unwrap_or_else(|| DEFAULT_SCOPE_NAME.to_string()); let queue_capacity = env_usize("SEQ_EVERRUNS_MAPLE_QUEUE_CAPACITY") .unwrap_or(DEFAULT_QUEUE_CAPACITY) .max(1); let max_batch_size = env_usize("SEQ_EVERRUNS_MAPLE_MAX_BATCH_SIZE") .unwrap_or(DEFAULT_MAX_BATCH_SIZE) .max(1); let flush_interval = Duration::from_millis( env_u64("SEQ_EVERRUNS_MAPLE_FLUSH_INTERVAL_MS").unwrap_or(DEFAULT_FLUSH_INTERVAL_MS), ); let connect_timeout = Duration::from_millis( env_u64("SEQ_EVERRUNS_MAPLE_CONNECT_TIMEOUT_MS").unwrap_or(DEFAULT_CONNECT_TIMEOUT_MS), ); let request_timeout = Duration::from_millis( env_u64("SEQ_EVERRUNS_MAPLE_REQUEST_TIMEOUT_MS").unwrap_or(DEFAULT_REQUEST_TIMEOUT_MS), ); Ok(Some(Self { service_name, service_version, deployment_environment, scope_name, queue_capacity, max_batch_size, flush_interval, connect_timeout, request_timeout, targets, })) } } impl Default for MapleExporterConfig { fn default() -> Self { Self { service_name: DEFAULT_SERVICE_NAME.to_string(), service_version: None, deployment_environment: DEFAULT_ENV.to_string(), scope_name: DEFAULT_SCOPE_NAME.to_string(), queue_capacity: DEFAULT_QUEUE_CAPACITY, max_batch_size: DEFAULT_MAX_BATCH_SIZE, flush_interval: Duration::from_millis(DEFAULT_FLUSH_INTERVAL_MS), connect_timeout: Duration::from_millis(DEFAULT_CONNECT_TIMEOUT_MS), request_timeout: Duration::from_millis(DEFAULT_REQUEST_TIMEOUT_MS), targets: Vec::new(), } } } #[derive(Debug, Error)] pub enum MapleConfigError { #[error("SEQ_EVERRUNS_MAPLE_TRACES_ENDPOINTS count ({endpoints}) does not match SEQ_EVERRUNS_MAPLE_INGEST_KEYS count ({keys})")] EndpointKeyCountMismatch { endpoints: usize, keys: usize }, #[error("{prefix} endpoint/key must both be set")] IncompletePair { prefix: &'static str }, } #[derive(Debug, Clone)] pub struct MapleSpan { pub trace_id: String, pub span_id: String, pub parent_span_id: String, pub name: String, pub kind: i32, pub start_time_unix_nano: u64, pub end_time_unix_nano: u64, pub status_code: i32, pub status_message: Option<String>, pub attributes: Vec<(String, String)>, } impl MapleSpan { pub fn for_runtime_event( session_id: &str, event_id: &str, stage: &str, ok: bool, error: Option<&str>, start_time_unix_nano: u64, end_time_unix_nano: u64, mut extra_attributes: Vec<(String, String)>, ) -> Self { let trace_id = stable_trace_id(session_id, event_id); let span_id = stable_span_id(&format!( "{session_id}:{event_id}:{stage}:{start_time_unix_nano}" )); extra_attributes.push(("session_id".to_string(), session_id.to_string())); extra_attributes.push(("event_id".to_string(), event_id.to_string())); extra_attributes.push(("stage".to_string(), stage.to_string())); extra_attributes.push(("bridge.ok".to_string(), ok.to_string())); if let Some(msg) = error { extra_attributes.push(("error.message".to_string(), msg.to_string())); } Self { trace_id, span_id, parent_span_id: String::new(), name: format!("everruns.{stage}"), kind: 1, start_time_unix_nano, end_time_unix_nano, status_code: if ok { 1 } else { 2 }, status_message: error.map(|s| s.to_string()), attributes: extra_attributes, } } pub fn for_tool_call( session_id: &str, event_id: &str, tool_call_id: &str, tool_name: &str, seq_op: &str, ok: bool, error: Option<&str>, start_time_unix_nano: u64, end_time_unix_nano: u64, duration_ms: u64, ) -> Self { let trace_id = stable_trace_id(session_id, event_id); let span_id = stable_span_id(&format!( "{session_id}:{event_id}:{tool_call_id}:{start_time_unix_nano}" )); let mut attributes = vec![ ("session_id".to_string(), session_id.to_string()), ("event_id".to_string(), event_id.to_string()), ("tool_call_id".to_string(), tool_call_id.to_string()), ("tool_name".to_string(), tool_name.to_string()), ("seq_op".to_string(), seq_op.to_string()), ("bridge.ok".to_string(), ok.to_string()), ("bridge.duration_ms".to_string(), duration_ms.to_string()), ]; if let Some(msg) = error { attributes.push(("error.message".to_string(), msg.to_string())); } Self { trace_id, span_id, parent_span_id: String::new(), name: "everruns.tool_call".to_string(), kind: 3, start_time_unix_nano, end_time_unix_nano, status_code: if ok { 1 } else { 2 }, status_message: error.map(|s| s.to_string()), attributes, } } } #[derive(Debug, Clone, Default)] pub struct MapleExporterStats { pub enqueued: u64, pub sent: u64, pub failed: u64, pub dropped: u64, } #[derive(Default)] struct MapleExporterStatsAtomic { enqueued: AtomicU64, sent: AtomicU64, failed: AtomicU64, dropped: AtomicU64, } struct WorkerTarget { traces_endpoint: String, ingest_key: String, client: Client, } pub struct MapleTraceExporter { tx: SyncSender<MapleSpan>, stats: Arc<MapleExporterStatsAtomic>, } impl MapleTraceExporter { pub fn from_env() -> Result<Option<Self>, MapleConfigError> { let Some(config) = MapleExporterConfig::from_env()? else { return Ok(None); }; Ok(Some(Self::new(config))) } pub fn new(config: MapleExporterConfig) -> Self { let (tx, rx) = sync_channel(config.queue_capacity.max(1)); let stats = Arc::new(MapleExporterStatsAtomic::default()); let worker_stats = Arc::clone(&stats); thread::spawn(move || worker_main(rx, config, worker_stats)); Self { tx, stats } } pub fn emit_span(&self, span: MapleSpan) { if self.tx.try_send(span).is_ok() { self.stats.enqueued.fetch_add(1, Ordering::Relaxed); } else { self.stats.dropped.fetch_add(1, Ordering::Relaxed); } } pub fn stats(&self) -> MapleExporterStats { MapleExporterStats { enqueued: self.stats.enqueued.load(Ordering::Relaxed), sent: self.stats.sent.load(Ordering::Relaxed), failed: self.stats.failed.load(Ordering::Relaxed), dropped: self.stats.dropped.load(Ordering::Relaxed), } } } fn worker_main( rx: Receiver<MapleSpan>, config: MapleExporterConfig, stats: Arc<MapleExporterStatsAtomic>, ) { let worker_targets: Vec<WorkerTarget> = config .targets .iter() .map(|target| WorkerTarget { traces_endpoint: target.traces_endpoint.clone(), ingest_key: target.ingest_key.clone(), client: Client::builder() .connect_timeout(config.connect_timeout) .timeout(config.request_timeout) .build() .expect("failed to build maple exporter HTTP client"), }) .collect(); let mut batch = Vec::with_capacity(config.max_batch_size.max(1)); let mut disconnected = false; while !disconnected { match rx.recv_timeout(config.flush_interval) { Ok(span) => batch.push(span), Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => { disconnected = true; } } while batch.len() < config.max_batch_size { match rx.try_recv() { Ok(span) => batch.push(span), Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { disconnected = true; break; } } } if !batch.is_empty() { flush_batch(&config, &worker_targets, &batch, &stats); batch.clear(); } } } fn flush_batch( config: &MapleExporterConfig, worker_targets: &[WorkerTarget], spans: &[MapleSpan], stats: &Arc<MapleExporterStatsAtomic>, ) { let spans_payload: Vec<Value> = spans.iter().map(encode_span).collect(); let resource_attrs = build_resource_attrs(config); let payload = json!({ "resourceSpans": [ { "resource": { "attributes": resource_attrs }, "scopeSpans": [ { "scope": { "name": config.scope_name }, "spans": spans_payload } ] } ] }); let body = payload.to_string(); for target in worker_targets { let sent = target .client .post(&target.traces_endpoint) .header("content-type", "application/json") .header("x-maple-ingest-key", &target.ingest_key) .body(body.clone()) .send(); match sent { Ok(resp) if resp.status().is_success() => { stats.sent.fetch_add(spans.len() as u64, Ordering::Relaxed); } Ok(_) | Err(_) => { stats .failed .fetch_add(spans.len() as u64, Ordering::Relaxed); } } } } fn build_resource_attrs(config: &MapleExporterConfig) -> Vec<Value> { let mut attrs = vec![ otlp_string_attr("service.name", &config.service_name), otlp_string_attr("deployment.environment", &config.deployment_environment), ]; if let Some(version) = &config.service_version { attrs.push(otlp_string_attr("service.version", version)); } attrs } fn encode_span(span: &MapleSpan) -> Value { json!({ "traceId": span.trace_id, "spanId": span.span_id, "parentSpanId": span.parent_span_id, "name": span.name, "kind": span.kind, "startTimeUnixNano": span.start_time_unix_nano.to_string(), "endTimeUnixNano": span.end_time_unix_nano.to_string(), "attributes": span .attributes .iter() .map(|(key, value)| otlp_string_attr(key, value)) .collect::<Vec<Value>>(), "status": { "code": span.status_code, "message": span.status_message.clone().unwrap_or_default() } }) } fn otlp_string_attr(key: &str, value: &str) -> Value { json!({ "key": key, "value": { "stringValue": value } }) } fn parse_targets_from_env() -> Result<Vec<MapleIngestTarget>, MapleConfigError> { let mut targets = Vec::new(); let local_endpoint = std::env::var("SEQ_EVERRUNS_MAPLE_LOCAL_ENDPOINT") .ok() .and_then(non_empty); let local_key = std::env::var("SEQ_EVERRUNS_MAPLE_LOCAL_INGEST_KEY") .ok() .and_then(non_empty); match (local_endpoint, local_key) { (Some(endpoint), Some(key)) => targets.push(MapleIngestTarget { traces_endpoint: endpoint, ingest_key: key, }), (None, None) => {} _ => { return Err(MapleConfigError::IncompletePair { prefix: "SEQ_EVERRUNS_MAPLE_LOCAL", }); } } let hosted_endpoint = std::env::var("SEQ_EVERRUNS_MAPLE_HOSTED_ENDPOINT") .ok() .and_then(non_empty); let hosted_key = std::env::var("SEQ_EVERRUNS_MAPLE_HOSTED_INGEST_KEY") .ok() .and_then(non_empty); match (hosted_endpoint, hosted_key) { (Some(endpoint), Some(key)) => targets.push(MapleIngestTarget { traces_endpoint: endpoint, ingest_key: key, }), (None, None) => {} _ => { return Err(MapleConfigError::IncompletePair { prefix: "SEQ_EVERRUNS_MAPLE_HOSTED", }); } } let csv_endpoints = split_csv_env("SEQ_EVERRUNS_MAPLE_TRACES_ENDPOINTS"); let csv_keys = split_csv_env("SEQ_EVERRUNS_MAPLE_INGEST_KEYS"); if !csv_endpoints.is_empty() || !csv_keys.is_empty() { if csv_endpoints.len() != csv_keys.len() { return Err(MapleConfigError::EndpointKeyCountMismatch { endpoints: csv_endpoints.len(), keys: csv_keys.len(), }); } for (endpoint, key) in csv_endpoints.into_iter().zip(csv_keys.into_iter()) { targets.push(MapleIngestTarget { traces_endpoint: endpoint, ingest_key: key, }); } } Ok(dedup_targets(targets)) } fn split_csv_env(key: &str) -> Vec<String> { std::env::var(key) .ok() .map(|raw| raw.split(',').filter_map(non_empty).collect()) .unwrap_or_default() } fn dedup_targets(targets: Vec<MapleIngestTarget>) -> Vec<MapleIngestTarget> { let mut out: Vec<MapleIngestTarget> = Vec::new(); for target in targets { let exists = out.iter().any(|existing| { existing.traces_endpoint == target.traces_endpoint && existing.ingest_key == target.ingest_key }); if !exists { out.push(target); } } out } fn env_usize(key: &str) -> Option<usize> { std::env::var(key) .ok() .and_then(|v| v.trim().parse::<usize>().ok()) } fn env_u64(key: &str) -> Option<u64> { std::env::var(key) .ok() .and_then(|v| v.trim().parse::<u64>().ok()) } fn non_empty(s: impl AsRef<str>) -> Option<String> { let value = s.as_ref().trim(); if value.is_empty() { None } else { Some(value.to_string()) } } pub fn stable_trace_id(session_id: &str, event_id: &str) -> String { let a = fnv1a64(session_id.as_bytes()); let b = fnv1a64(event_id.as_bytes()); format!("{a:016x}{b:016x}") } pub fn stable_span_id(seed: &str) -> String { format!("{:016x}", fnv1a64(seed.as_bytes())) } fn fnv1a64(data: &[u8]) -> u64 { let mut hash: u64 = 0xcbf29ce484222325; for byte in data { hash ^= *byte as u64; hash = hash.wrapping_mul(0x100000001b3); } hash } #[cfg(test)] mod tests { use super::*; use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::Mutex; use std::time::Duration; static ENV_LOCK: Mutex<()> = Mutex::new(()); fn unset_maple_envs() { let keys = [ "SEQ_EVERRUNS_MAPLE_LOCAL_ENDPOINT", "SEQ_EVERRUNS_MAPLE_LOCAL_INGEST_KEY", "SEQ_EVERRUNS_MAPLE_HOSTED_ENDPOINT", "SEQ_EVERRUNS_MAPLE_HOSTED_INGEST_KEY", "SEQ_EVERRUNS_MAPLE_TRACES_ENDPOINTS", "SEQ_EVERRUNS_MAPLE_INGEST_KEYS", "SEQ_EVERRUNS_MAPLE_SERVICE_NAME", "SEQ_EVERRUNS_MAPLE_SERVICE_VERSION", "SEQ_EVERRUNS_MAPLE_ENV", "SEQ_EVERRUNS_MAPLE_SCOPE_NAME", "SEQ_EVERRUNS_MAPLE_QUEUE_CAPACITY", "SEQ_EVERRUNS_MAPLE_MAX_BATCH_SIZE", "SEQ_EVERRUNS_MAPLE_FLUSH_INTERVAL_MS", "SEQ_EVERRUNS_MAPLE_CONNECT_TIMEOUT_MS", "SEQ_EVERRUNS_MAPLE_REQUEST_TIMEOUT_MS", ]; for key in keys { std::env::remove_var(key); } } #[test] fn stable_ids_have_expected_length() { let trace_id = stable_trace_id("session-1", "event-1"); let span_id = stable_span_id("session-1:event-1:tc1"); assert_eq!(trace_id.len(), 32); assert_eq!(span_id.len(), 16); } #[test] fn reads_dual_target_env_config() { let _guard = ENV_LOCK.lock().expect("lock env"); unset_maple_envs(); std::env::set_var( "SEQ_EVERRUNS_MAPLE_LOCAL_ENDPOINT", "http://ingest.maple.localhost/v1/traces", ); std::env::set_var("SEQ_EVERRUNS_MAPLE_LOCAL_INGEST_KEY", "maple_pk_local"); std::env::set_var( "SEQ_EVERRUNS_MAPLE_HOSTED_ENDPOINT", "https://ingest.1focus.ai/v1/traces", ); std::env::set_var("SEQ_EVERRUNS_MAPLE_HOSTED_INGEST_KEY", "maple_pk_hosted"); let cfg = MapleExporterConfig::from_env() .expect("env parse") .expect("config should exist"); assert_eq!(cfg.targets.len(), 2); assert!(cfg .targets .iter() .any(|t| t.traces_endpoint == "http://ingest.maple.localhost/v1/traces")); assert!(cfg .targets .iter() .any(|t| t.traces_endpoint == "https://ingest.1focus.ai/v1/traces")); unset_maple_envs(); } #[test] fn csv_target_env_mismatch_returns_error() { let _guard = ENV_LOCK.lock().expect("lock env"); unset_maple_envs(); std::env::set_var( "SEQ_EVERRUNS_MAPLE_TRACES_ENDPOINTS", "http://ingest.maple.localhost/v1/traces,https://ingest.1focus.ai/v1/traces", ); std::env::set_var("SEQ_EVERRUNS_MAPLE_INGEST_KEYS", "maple_pk_only_one"); let err = MapleExporterConfig::from_env().expect_err("mismatch should error"); assert!(matches!( err, MapleConfigError::EndpointKeyCountMismatch { .. } )); unset_maple_envs(); } #[test] fn incomplete_local_pair_returns_error() { let _guard = ENV_LOCK.lock().expect("lock env"); unset_maple_envs(); std::env::set_var( "SEQ_EVERRUNS_MAPLE_LOCAL_ENDPOINT", "http://ingest.maple.localhost/v1/traces", ); let err = MapleExporterConfig::from_env().expect_err("incomplete pair should error"); assert!(matches!( err, MapleConfigError::IncompletePair { prefix: "SEQ_EVERRUNS_MAPLE_LOCAL" } )); unset_maple_envs(); } #[test] fn exporter_sends_span_to_ingest_endpoint() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); let addr = listener.local_addr().expect("local addr"); let server = std::thread::spawn(move || { if let Ok((mut stream, _)) = listener.accept() { let mut req = [0_u8; 8192]; let _ = stream.read(&mut req); let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}"; let _ = stream.write_all(response); let _ = stream.flush(); } }); let config = MapleExporterConfig { service_name: "seq-everruns-bridge-test".to_string(), service_version: None, deployment_environment: "test".to_string(), scope_name: "seq_everruns_bridge".to_string(), queue_capacity: 32, max_batch_size: 8, flush_interval: Duration::from_millis(10), connect_timeout: Duration::from_millis(200), request_timeout: Duration::from_millis(200), targets: vec![MapleIngestTarget { traces_endpoint: format!("http://{addr}/v1/traces"), ingest_key: "maple_pk_test".to_string(), }], }; let exporter = MapleTraceExporter::new(config); let span = MapleSpan::for_tool_call( "session-1", "event-1", "tool-1", "seq_ping", "ping", true, None, 1_739_890_000_000_000_000, 1_739_890_000_100_000_000, 100, ); exporter.emit_span(span); std::thread::sleep(Duration::from_millis(80)); let stats = exporter.stats(); assert!(stats.sent >= 1, "expected at least one sent span"); let _ = server.join(); } } ================================================ FILE: docs/ai-dev-layout.md ================================================ # `.ai/` Dev Layout Use `.ai/` aggressively for local AI-assisted development, but keep the split between tracked project intelligence and disposable local artifacts explicit. Tracked in this repo: - `.ai/docs/` - `.ai/recipes/` - `.ai/repos.toml` - curated `.ai/skills/` entries that are intentionally committed Ignored local-dev buckets: - `.ai/reviews/` - generated PR feedback packets and review snapshots - `.ai/test/` - AI scratch tests and local validation files - `.ai/tmp/` - throwaway intermediate files - `.ai/cache/` - cached derivations or precomputed local state - `.ai/artifacts/` - generated outputs worth inspecting locally but not committing - `.ai/traces/` - local trace dumps or trace export scratch - `.ai/generated/` - one-off generated code/docs that should be promoted elsewhere if kept - `.ai/scratch/` - free-form local notes, prompts, and experiments Rules of thumb: 1. If it is canonical project knowledge, promote it out of the local bucket and track it intentionally. 2. If it is generated, per-machine, or iteration-only, keep it under one of the ignored buckets above. 3. Prefer `.ai/` over random top-level temp files so local AI/dev state stays contained and easy to clean up. ================================================ FILE: docs/ai-run-task-fast-path.md ================================================ # AI Run Task Fast Path Goal: let AI add a run task in one edit, return a runnable `f ...` command, and optionally push. ## Prompt Contract ```text make public task <doc-path.md> <thing> make internal task <doc-path.md> <thing> ``` `<doc-path.md>` is implementation context. Do not edit docs unless asked. ## File Targeting - Public: `~/run/flow.toml` - Internal shared: `~/run/i/flow.toml` - Internal project (only if project is explicitly requested): `~/run/i/<project>/flow.toml` ## Fast Rules - Read only: - `<doc-path.md>` - target `flow.toml` - Edit one `flow.toml` file. - Add one `[[tasks]]` block unless user asks for multiple. - Required fields: `name`, `description`, `command`. - Add `interactive = true` only for prompts/TUI. - Use `"$@"` passthrough when args should flow through. - Keep shell idempotent and non-destructive. ## Required AI Reply Format ```text Changed: - /abs/path/to/flow.toml Run: f <r|ri|rip ...> <task-name> [args] Share: cd <repo> && git add <flow.toml> && git commit -m "add <task-name>" && git push ``` Shortcut map: - Public -> `f r <task>` - Internal shared -> `f ri <task>` - Internal project -> `f rip <project> <task>` ## Push Confidence Gate Include `Share` command only when all are true: - single-file `flow.toml` change - no secrets - no destructive commands - no uncertain behavior Otherwise: ```text Share: skip (manual review) ``` ================================================ FILE: docs/ai-task-fast-path-guide.md ================================================ # MoonBit AI Task Fast Path Guide This guide is the practical playbook for running Flow MoonBit AI tasks at the lowest possible invocation latency. It covers: - when to use `f` vs `fai` - how daemon mode actually works - exact env knobs for tuning - benchmark workflow to validate improvements - troubleshooting for common regressions --- ## 1. Runtime Modes Flow supports multiple runtime paths for `.ai/tasks/*.mbt`: 1. `moon run` path `FLOW_AI_TASK_RUNTIME=moon-run f ai:flow/dev-check` Highest flexibility, highest overhead. 2. Cached binary path through `f` `FLOW_AI_TASK_RUNTIME=cached f ai:flow/dev-check` Uses build cache, still pays full `f` process startup. 3. Daemon path through `f` `f tasks run-ai --daemon ai:flow/dev-check` Uses `ai-taskd` over Unix socket, still pays `f` process startup. 4. Fast daemon client (`fai`) `fai ai:flow/dev-check` Lowest invocation overhead for hot loops. --- ## 2. Recommended Setup (Low Latency) From `~/code/flow`: ```bash f install-ai-fast-client f tasks daemon start ``` What this gives you: - `~/.local/bin/fai` installed (low-overhead client). - `ai-taskd` running and warm (`~/.flow/run/ai-taskd.sock`). Verify: ```bash which fai fai --help f tasks daemon status fai ai:flow/noop ``` For always-on daemon across login sessions (recommended for stable latency): ```bash f ai-taskd-launchd-install f ai-taskd-launchd-status ``` --- ## 3. Fast-Path Architecture ### `fai` path 1. `fai` sends a compact request to `~/.flow/run/ai-taskd.sock` 2. `ai-taskd` resolves task selector (fast exact path first) 3. `ai-taskd` reuses cached binary artifact when available 4. task process runs with `FLOW_AI_TASK_PROJECT_ROOT` set ### Key optimizations in current implementation - daemon discovery cache with TTL: - `FLOW_AI_TASKD_DISCOVERY_TTL_MS` (default `750`) - daemon artifact cache with TTL: - `FLOW_AI_TASKD_ARTIFACT_TTL_MS` (default `1500`) - fast selector resolution: - exact selectors skip full recursive task discovery - faster cache key computation: - file metadata fingerprints instead of full content hashing - Moon version cached on disk with TTL Moon version knobs: - `FLOW_AI_TASK_MOON_VERSION` (explicit override) - `FLOW_AI_TASK_MOON_VERSION_TTL_SECS` (default `43200`) Wire protocol knobs: - `fai --protocol msgpack` (default) - `fai --protocol json` (compat / debugging) --- ## 4. Using `f` with Fast Client Preference `f` can optionally route AI task dispatch through the fast client when daemon mode is enabled. Required: ```bash export FLOW_AI_TASK_DAEMON=1 export FLOW_AI_TASK_FAST_CLIENT=1 ``` Optional selector control: ```bash export FLOW_AI_TASK_FAST_SELECTORS='ai:flow/noop,ai:flow/bench-cli,ai:project/*' ``` Optional client binary override: ```bash export FLOW_AI_TASK_FAST_CLIENT_BIN="$HOME/.local/bin/fai" ``` Without `FLOW_AI_TASK_FAST_CLIENT=1`, `f` keeps normal daemon behavior. --- ## 5. `fai` CLI Usage ```bash fai [--root PATH] [--socket PATH] [--protocol json|msgpack] [--no-cache] [--capture-output] [--timings] <selector> [-- <args...>] fai [--root PATH] [--socket PATH] [--protocol json|msgpack] [--no-cache] [--capture-output] [--timings] --batch-stdin ``` Examples: ```bash fai ai:flow/noop fai --root ~/code/flow ai:flow/bench-cli -- --iterations 50 fai --no-cache ai:flow/dev-check fai --timings ai:flow/noop printf 'ai:flow/noop\nai:flow/noop\n' | fai --batch-stdin ``` Notes: - default is no-capture mode for lower overhead - use `--capture-output` if you need command output returned through client response - use `--timings` to print server-side phase timings (`resolve_us`, `run_us`, `total_us`) - use `--batch-stdin` for pooled client bursts (single client process, multiple requests) --- ## 6. Benchmark Procedure Run baseline runtime benchmark: ```bash f bench-ai-runtime --iterations 80 --warmup 10 --json-out /tmp/flow_ai_runtime.json ``` This includes: - `moon_run_noop` - `cached_noop` - `daemon_cached_noop` - `cached_binary_direct` - `daemon_client_noop` (if `ai-taskd-client` binary is present) For focused hot-loop comparisons: ```bash python3 - <<'PY' import subprocess,time,statistics from pathlib import Path root=Path('~/code/flow').expanduser() cases=[ ('f_daemon',['./target/debug/f','tasks','run-ai','--daemon','ai:flow/noop']), ('fai',['fai','ai:flow/noop']), ('f_cached',['./target/debug/f','ai:flow/noop']), ] for name,cmd in cases: xs=[] for i in range(60): t0=time.perf_counter() p=subprocess.run(cmd,cwd=root,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) dt=(time.perf_counter()-t0)*1000 if p.returncode!=0: raise SystemExit((name,p.returncode)) if i>=10: xs.append(dt) xs=sorted(xs) pct=lambda p: xs[int((len(xs)-1)*p)] print(name,'p50',round(pct(0.5),2),'p95',round(pct(0.95),2),'mean',round(statistics.mean(xs),2)) PY ``` --- ## 7. Operational Recommendations Use this default profile for lowest latency: ```bash export FLOW_AI_TASK_DAEMON=1 export FLOW_AI_TASK_FAST_CLIENT=1 export FLOW_AI_TASK_FAST_SELECTORS='ai:flow/*' f tasks daemon start ``` Then: - latency-critical loops: `fai ai:...` - normal dev ergonomics: `f ai:...` (auto fast-client when selectors match) --- ## 8. Troubleshooting ### `fai` says cannot connect to socket Start daemon: ```bash f tasks daemon start ``` Check: ```bash f tasks daemon status ls -l ~/.flow/run/ai-taskd.sock ``` Or install persistent daemon: ```bash f ai-taskd-launchd-install ``` ### Task not found with `fai` Use full selector: ```bash f tasks list | rg '^ai:' fai ai:flow/dev-check ``` ### Latency suddenly regressed Check: ```bash ps -Ao pcpu,pmem,comm | sort -k1 -nr | head -n 20 f tasks daemon status ``` Then rerun benchmark with warmup. ### Need strict correctness instead of lowest overhead Use `--capture-output` on `fai` for output-capture parity. ### Need in-daemon stage attribution for profiling ```bash fai --timings ai:flow/noop FLOW_AI_TASKD_TIMINGS_LOG=1 f tasks daemon serve ``` --- ## 9. Implemented Optimization Set Implemented in this iteration: 1. always-on daemon support via launchd tasks (`ai-taskd-launchd-*`) 2. binary request framing support (`msgpack`) in `fai` + `ai-taskd` 3. pooled client burst mode via `fai --batch-stdin` 4. per-request stage timings exposed via `fai --timings` and daemon timing logs Potential next frontier: 1. keep a persistent client-side socket session with framed multi-request protocol 2. add lock-free shared-memory ring for local burst dispatch if socket overhead becomes dominant 3. push per-stage timing aggregation into benchmark JSON outputs for automatic regression gating ================================================ FILE: docs/ai-traces.md ================================================ # AI Traces (Flow) Use this doc when collecting context for debugging or development work in `~/code/flow`. The goal is to keep trace collection low overhead while still capturing enough signal for fast fixes. ## Quick start - Proxy traces summary: - `~/.config/flow/proxy/trace-summary.json` - Last trace event (CLI): - `f proxy last` - Stream traces (CLI): - `f proxy trace` ## Trace sources - **Proxy ring buffer summary** - Path: `~/.config/flow/proxy/trace-summary.json` - **Fish shell IO traces** - Path: `${XDG_DATA_HOME:-~/.local/share}/fish/io-trace/last.meta` - Command: `fish-trace last` ## When to use which - Start with the summary file to find errors/slow requests. - Use `f proxy last` when you need the newest request details. - Use `f proxy trace` to tail a stream during repro. ## Safety notes - Summary parsing is cheap and safe to run anytime. - If proxy is not running, `f proxy` commands will fail; fall back to the summary file and fish traces. ## Skills + harness loading Codex skills are discovered from `~/.codex/skills` and injected by the AI harness. If a new skill was added or edited, restart the agent or re-list skills to refresh. This repo ships trace guidance via the `flow-dev-traces` skill. ================================================ FILE: docs/anonymous-telemetry-contract.md ================================================ # Anonymous Telemetry Contract (Flow CLI) Flow emits anonymous command telemetry for product/training quality when analytics is enabled. ## Event - `type`: `flow.command.v1` - `schema_version`: `1` - `event_id`: UUID - `name`: normalized command path (for unknown commands: `task-shortcut`) - `ok`: command success - `at`: event timestamp (ms) - `source`: `flow-cli` - `payload`: - `anon_user_id` (rotates every 30 days) - `project_fingerprint` (rotates every 30 days) - `command_path` - `success` - `exit_code` (currently null) - `duration_ms` - `flags_used` - `flow_version` - `os` - `arch` - `interactive` - `ci` ## Privacy Guarantees - No usernames/emails. - No prompts/assistant messages. - No file contents. - No absolute paths (project fingerprint is HMAC-based and rotated). - No stable raw install identifier in payload. ## Endpoint Default endpoint: `https://api.myflow.sh/api/telemetry/flow` Can be overridden in `flow.toml`: ```toml [analytics] endpoint = "https://api.myflow.sh/api/telemetry/flow" enabled = true sample_rate = 1.0 ``` ================================================ FILE: docs/ascii-commit-visualization-pipeline.md ================================================ # ASCII Commit Visualization Pipeline This explains how commit analysis data becomes ASCII-style diagrams in myflow. Scope: - generation and storage in Flow - API serving via Flow server - runtime diagram rendering in myflow via `box-of-rain` --- ## 1. Commit Analysis Generation Flow generates commit explanations with: ```bash f explain-commits 3 --force ``` Implementation: - `src/explain_commits.rs` - Uses `ai-task.sh` with provider/model fixed to Kimi defaults (`nvidia` + `moonshotai/kimi-k2.5`). - For each commit, Flow gathers: - `sha`, `short_sha`, `subject`, `author`, `date` - file list from `git diff --name-only` - truncated diff payload (max chars guard) Output per project (default): - `docs/commits/<date>-<short_sha>-<slug>.md` - `docs/commits/<date>-<short_sha>-<slug>.json` - `docs/commits/.index.json` (digest/index cache) Notes: - `--force` bypasses digest skip logic. - `--out-dir` can override default output location. --- ## 2. Storage Format The sidecar `.json` mirrors Flow’s `ExplainedCommit` shape: - `sha` - `short_sha` - `subject` - `author` - `date` - `summary` - `changes` - `files` (array of changed file paths) - `markdown_file` - `generated_at` This is the source of truth consumed by the UI. --- ## 3. API Serving Layer Flow server exposes commit explanations over HTTP: - `GET /projects/:name/commit-explanations?limit=50` - `GET /projects/:name/commit-explanations/:sha` Implementation: - `src/log_server.rs`: - `project_commit_explanations` - `project_commit_explanation_detail` - data loader functions are in `src/explain_commits.rs`: - `list_explained_commits` - `get_explained_commit` --- ## 4. myflow Data Consumption myflow fetches these endpoints through `flowFetch` model atoms: - `/projects/$project/commit-explanations` - `/projects/$project/commit-explanations/$sha` Model file: - `~/code/myflow/web/lib/models/flow-projects.ts` Relevant type: - `FlowExplainedCommit` --- ## 5. Diagram Generation (ASCII -> SVG) Diagram rendering is client-side in myflow and uses `box-of-rain`. Theme/options: - `~/code/myflow/web/lib/diagram-theme.ts` - shared `DIAGRAM_SVG_OPTIONS`: - transparent background - mono font - light/dark foreground colors ### 5.1 What `box-of-rain` actually does `box-of-rain` has two explicit stages: 1. layout stage: `render(nodeDef)` Input is a graph description (`NodeDef` + `connections`). Output is a multiline ASCII canvas (boxes, borders, arrows, connectors). 2. paint stage: `renderSvg(ascii, svgOptions)` Input is the ASCII text grid. Output is an SVG string where each character is positioned with fixed-width metrics. Important: layout and paint are separate. If shape/connectors are wrong, the bug is in `NodeDef`/connections. If colors/spacing/fonts are wrong, the bug is in `SvgOptions`. Core graph primitives used in myflow: - `id`: stable node identifier for edge wiring. - `children`: lines rendered inside a box. - `border`: visual style (`rounded`, `bold`). - `childDirection`: relative arrangement (`horizontal`). - `connections`: explicit edges, each with: - `from`, `to` - `fromSide`, `toSide` (`left|right|top|bottom`). Timeline shape (conceptual): ```text c0 ──> c1 ──> c2 ``` Files impact shape (conceptual): ```text commit ──> dirA commit ──> dirB commit ──> dirC ``` ### Timeline diagram File: - `~/code/myflow/web/lib/commit-timeline-diagram.tsx` Algorithm: 1. take up to 8 newest commits 2. reverse to oldest -> newest 3. build one rounded node per commit: - line 1: `short_sha` - lines 2+: truncated subject (2 lines max) 4. connect node `i -> i+1` (right to left sides) 5. render: - `render(nodeDef)` -> ASCII layout - `renderSvg(ascii, DIAGRAM_SVG_OPTIONS)` -> SVG 6. inject SVG into DOM with `dangerouslySetInnerHTML` Conceptual `NodeDef` sketch: ```ts { children: [ { id: "c0", border: "rounded", children: ["2daa3fd", "feat: sub-agent"] }, { id: "c1", border: "rounded", children: ["f298c48", "memories rollout"] }, ], childDirection: "horizontal", connections: [{ from: "c0", to: "c1", fromSide: "right", toSide: "left" }], } ``` Mounted at: - `~/code/myflow/web/pages/flow/$project/index.tsx` ### Files impact diagram File: - `~/code/myflow/web/lib/files-impact-diagram.tsx` Algorithm: 1. group `commit.files` by top path bucket: - first 2 segments when possible 2. create bold commit node 3. create one rounded directory node per group: - dir label - up to 3 file names - `+N more` overflow line 4. connect `commit -> each_dir` 5. render ASCII then SVG with same theme options Conceptual `NodeDef` sketch: ```ts { children: [ { id: "commit", border: "bold", children: ["2daa3fd", "feat: sub-agent"] }, { id: "d0", border: "rounded", children: ["codex-rs/core/", " agent.rs", " mod.rs"] }, ], childDirection: "horizontal", connections: [{ from: "commit", to: "d0", fromSide: "right", toSide: "left" }], } ``` Mounted at: - `~/code/myflow/web/pages/flow/$project/commit/$sha.tsx` --- ## 6. Performance and Limits - both diagram components are wrapped in `useMemo` - timeline hard limit: 8 commits - subject lines are truncated for stable node widths - files list per group is capped in-node (full list still shown below diagram) --- ## 7. Common Failure Modes 1. No commit data: - run `f explain-commits N` in the target repo 2. API empty: - ensure Flow server is running (`f server`) - ensure project is registered in Flow 3. Diagram package missing: - ensure `box-of-rain` dependency resolves in myflow runtime build 4. BetterAuth `/api` base URL error in browser: - use absolute URL normalization in `web/lib/auth-client.ts` (relative `/api` alone is invalid for BetterAuth client config) --- ## 8. Quick End-to-End Check From target repo (example: codex): ```bash cd ~/repos/openai/codex f explain-commits 3 --force ``` From Flow: ```bash f server --host 127.0.0.1 --port 9050 curl 'http://127.0.0.1:9050/projects/codex/commit-explanations?limit=3' ``` Then open myflow: - project view: `/flow/codex` - commit view: `/flow/codex/commit/<sha>` ================================================ FILE: docs/bench/cli-startup.md ================================================ # Flow CLI Startup Benchmark Use this to measure cold-ish user-facing Flow latency on cheap commands that should stay fast. ## Run ```bash f bench-cli-startup ``` Useful knobs: ```bash f bench-cli-startup -- --iterations 30 --warmup 5 f bench-cli-startup -- --flow-bin ./target/release/f f bench-cli-startup -- --json-out out/bench/cli-startup.json ``` ## What it measures - `f --help` - `f --help-full` - `f info` - `f projects` - `f analytics status` - `f tasks list` - `f tasks dupes` The script forces: - `CI=1` - `FLOW_ANALYTICS_DISABLE=1` That keeps the benchmark focused on Flow startup and command dispatch instead of analytics prompts. ## Readouts to track - `p50_ms` - `p95_ms` - `p99_ms` - `mean_ms` For startup work, keep the policy simple: - optimize only if median improves - reject changes that materially regress `p95` - benchmark from the same binary and same repo fixture ## Why these commands These commands are the best early signal for startup overhead because they are local, cheap, non-interactive, and sensitive to unnecessary pre-dispatch work such as eager secrets loading and auto skill sync. ================================================ FILE: docs/bench/moonbit-rust-ffi-boundary.md ================================================ # MoonBit <-> Rust FFI Boundary Benchmark This benchmark measures raw call overhead between MoonBit (native C backend) and Rust host functions. ## Scope Measured: - Rust local math baseline (`rust_inline_add`, `rust_fn_add`) - Rust calling exported C ABI functions (`rust_extern_add`, `rust_extern_noop`) - MoonBit calling Rust-exported C ABI (`moon_ffi_add`, `moon_ffi_noop`) Not measured: - app-level task execution - process startup/teardown - end-to-end Flow command latency ## Where the code lives - `bench/ffi_host_boundary/src/lib.rs` - `bench/ffi_host_boundary/src/bin/rust_boundary_bench.rs` - `bench/moon_ffi_boundary/main.mbt` - `bench/moon_ffi_boundary/moon.pkg.template.json` - `scripts/bench-moonbit-rust-ffi.py` - Flow task: `bench-ffi-boundary` in `flow.toml` ## Run commands Direct script: ```bash cd ~/code/flow python3 scripts/bench-moonbit-rust-ffi.py --iters 10000000 --json-out /tmp/ffi.json ``` With machine-local tuning flags: ```bash cd ~/code/flow python3 scripts/bench-moonbit-rust-ffi.py --iters 10000000 --native-opt --json-out /tmp/ffi_native.json ``` Via Flow: ```bash cd ~/code/flow f bench-ffi-boundary --iters 10000000 --json-out /tmp/ffi_flow.json ``` ## Latest measured numbers (this machine) Method: 3 rounds each, 10M iterations/round, median ns/op. | metric | baseline | native-opt | tuned/base | |---|---:|---:|---:| | rust_extern_add | 2.7683 | 2.8291 | 1.022x | | rust_extern_noop | 2.7880 | 2.9140 | 1.045x | | moon_ffi_add | 0.9005 | 1.1075 | 1.230x | | moon_ffi_noop | 0.8576 | 0.8462 | 0.987x | Interpretation: - Boundary overhead is single-digit nanoseconds. - On this machine, `--native-opt` did not consistently improve results; it was mixed/slightly worse for most metrics. - You must benchmark on your target machine before locking optimization flags. ## Important measurement caveat `moon_add` (pure MoonBit loop) can be optimized away by the compiler and report `0 ns`. Treat it as non-authoritative for boundary decisions. Use FFI metrics (`moon_ffi_*`, `rust_extern_*`) as the primary signal. ## Optimizations implemented 1. Exported host functions stripped to minimal arithmetic (removed internal `black_box`). 2. Rust FFI functions marked `#[inline(never)]` to avoid accidental inlining in host-side tests. 3. Rust bench crate release profile tightened: - `lto = "fat"` - `codegen-units = 1` - `panic = "abort"` - `strip = true` 4. Moon native flags are configurable via template placeholder: - `cc-flags` in `moon.pkg.template.json` 5. Benchmark script supports `--native-opt`: - Rust: `RUSTFLAGS=-C target-cpu=native` - Moon native: `-O3 -march=native -mtune=native` ## Ideas borrowed from Moon toolchain From `~/repos/moonbitlang/moon`: - `Cargo.toml` uses explicit release profile controls (`lto`, `codegen-units`, `strip`) for predictable build/runtime tradeoffs. - Native pipeline exposes per-package `cc-flags` / `cc-link-flags` (schema in `crates/moonbuild/template/pkg.schema.json`). - TCC-run mode exists to speed dev-time run loops, not runtime performance; custom native flags disable that fast dev path. Practical implication: - For runtime benchmarks, tune native flags carefully and measure. - For developer iteration speed, avoid unnecessary custom flags when tcc-run is beneficial. ## Decision guidance: should Rust core move to MoonBit for speed? Based on these numbers: no, not for boundary speed alone. Reason: - Boundary is already near-zero cost (sub-3ns on this host). - Any wins from migration will mostly come from architecture choices (fewer crossings, coarser APIs), not language-switch micro-optimizations. Move code to MoonBit for: - faster iteration/modeling - portability or generation benefits - maintainability Keep in Rust when: - syscall-heavy paths - mature unsafe/perf-critical internals already stable ## Next benchmark to add (recommended) Add a coarse-grained benchmark for real task payload crossing: - one boundary call carrying a packed command - compare against N tiny calls - report payload sizes and p50/p95 That will better predict real Flow task runtime behavior than arithmetic no-op microbenchmarks. ================================================ FILE: docs/bench/readme.md ================================================ # Bench Docs - `moonbit-rust-ffi-boundary.md`: raw MoonBit <-> Rust boundary benchmark, commands, results, and optimization guidance. - `cli-startup.md`: repeatable benchmark for Flow CLI startup and cheap read-only command latency. ================================================ FILE: docs/ci-cd-runbook.md ================================================ # Flow CI/CD Runbook This runbook documents how Flow CI/CD is wired today and how to debug it quickly when jobs fail. ## Architecture - Workflows: - `.github/workflows/pr-fast.yml`: fast PR gate (Linux) using vendored hydrate + `cargo check`. - `.github/workflows/canary.yml`: runs on every push to `main`, publishes/updates the `canary` release/tag. - `.github/workflows/nightly-validation.yml`: scheduled full cross-target validation (plus host SIMD) without publishing. - `.github/workflows/release.yml`: runs on tag pushes matching `v*`, publishes stable releases. - Trigger optimization: - Canary `push` skips docs-only changes (`docs/**`, `**/*.md`). - Use `workflow_dispatch` to force a Canary run when needed after docs-only changes. - Vendored deps bootstrap: - Both workflows run `scripts/vendor/vendor-repo.sh hydrate` immediately after checkout in each build job. - This materializes `lib/vendor/*` from the pinned commit in `vendor.lock.toml` before Cargo builds. - Build jobs cache vendor checkout/materialization (`.vendor/flow-vendor`, `lib/vendor`, `lib/vendor-manifest`) keyed by `vendor.lock.toml`. - Repository policy checks: - CI enforces lowercase `readme.md` naming via `scripts/ci/check-readme-case.sh`. - Build jobs in both workflows: - Matrix build: macOS + Linux targets. - SIMD build: `build-linux-host-simd` (Linux x64 with `--features linux-host-simd-json`). - CI builds `--bin f` only (release artifacts package `f`; avoids duplicate `flow` alias build cost). - Release jobs: - Gather all build artifacts. - Publish release assets (and in Canary, force-move `canary` tag to current `main` commit). - `release` waits for both `build` and `build-linux-host-simd`. ## Runner Modes Flow uses task-driven mode switching (not manual workflow edits): - `github` mode: - Standard Linux lanes on `ubuntu-latest`. - SIMD lane disabled. - `blacksmith` mode: - Linux lanes on Blacksmith runners. - SIMD lane enabled on Blacksmith. - `host` mode: - Standard Linux lanes stay on GitHub-hosted runners. - SIMD lane runs on self-hosted label: `[self-hosted, linux, x64, ci-1focus]`. Check/switch mode: ```bash f ci-blacksmith-status f ci-blacksmith-enable f ci-blacksmith-enable-apply f ci-host-enable f ci-host-enable-apply f ci-blacksmith-disable f ci-blacksmith-disable-apply ``` ## One-Command Host Setup Preferred path (painless, idempotent): ```bash f ci-host-setup ``` If infra host is not configured yet: ```bash f ci-host-setup <user@ip> ``` What `f ci-host-setup` does: 1. Validates `gh` auth and `infra` host config. 2. Installs/registers the `ci-1focus` self-hosted runner on the Linux host. 3. Waits for runner to report online. 4. Switches workflows to `host` mode with commit + push. 5. Prints final runner health/status. ## Daily Operations - Check current mode: `f ci-blacksmith-status` - Check runner service + GitHub registration: `f ci-host-runner-status` - Reinstall runner if needed: `f ci-host-runner-install` - Remove runner: `f ci-host-runner-remove` Stable release flow: 1. Merge version bump to `main`. 2. Push tag `vX.Y.Z`. 3. Watch `Release` workflow. Canary flow: 1. Push to `main`. 2. Watch `Canary` workflow. 3. Confirm `canary` tag moved and release assets updated. PR flow: 1. Open/refresh PR to `main`. 2. Watch `PR Fast Check` (`cargo check` + `cargo test --no-run` shard). 3. Merge only after fast check passes. ## Debug Playbook ### 1) Workflow failed or stuck ```bash gh run list --workflow Canary --limit 10 gh run list --workflow Release --limit 10 gh run view <run-id> --log-failed gh run watch <run-id> ``` If failure shows: - `failed to load source for dependency 'axum'` - `Unable to update .../lib/vendor/axum` - `No such file or directory (os error 2)` then vendored deps were not hydrated before build. ### 2) SIMD lane queued forever Usually means self-hosted runner routing issue. ```bash f ci-blacksmith-status f ci-host-runner-status python3 ./scripts/ci_host_runner.py health --repo nikivdev/flow ``` Expected healthy state is: - Host service: `active` - GitHub runner status: `online` - Runner has label `ci-1focus` If not healthy, run: ```bash f ci-host-runner-install python3 ./scripts/ci_host_runner.py wait-online --repo nikivdev/flow --timeout-secs 120 --interval-secs 5 ``` ### 3) Workflows not using expected runner profile ```bash f ci-blacksmith-status ``` If wrong: ```bash f ci-host-enable-apply # or: f ci-blacksmith-enable-apply # or: f ci-blacksmith-disable-apply ``` ### 3b) Vendored repo hydrate issues Hydration depends on `vendor.lock.toml` pin and vendor repo availability. Quick checks: ```bash scripts/vendor/vendor-repo.sh status scripts/vendor/vendor-repo.sh hydrate ``` Expected: - pinned commit in `vendor.lock.toml` is non-empty, - hydrate logs `hydrated <crate> -> lib/vendor/<crate>`, - `lib/vendor/axum/Cargo.toml` and `lib/vendor/reqwest/Cargo.toml` exist after hydrate. If CI cannot clone SSH URL from lock, `vendor-repo.sh` now falls back to HTTPS clone for GitHub URLs. ### 4) `curl ... install.sh` does not fetch expected fresh build Flow installer defaults to `canary` unless `FLOW_VERSION` is set differently. Check if `canary` moved: ```bash git ls-remote --tags origin canary ``` Then verify in sandbox (recommended) using: - `docs/rise-sandbox-feature-test-runbook.md` That runbook gives an isolated `rise sandbox` smoke test for: ```bash curl -fsSL https://myflow.sh/install.sh | sh ~/.flow/bin/f --version ``` ### 5) Setup task fails mid-install Re-run: ```bash f ci-host-setup ``` The installer path is idempotent (it removes old service/config before re-registering). If failure persists, inspect: ```bash f ci-host-runner-status gh api repos/nikivdev/flow/actions/runners ``` ## Notes - CI/CD execution is defined in repo workflows; GitHub UI is control plane/visibility (runs, logs, runner state), not the source of truth for pipeline logic. - Current performance balance: keep general Linux matrix jobs on GitHub-hosted runners, offload expensive SIMD build to the Linux host. ================================================ FILE: docs/codex-first-control-plane-roadmap.md ================================================ # Codex-First Control Plane Roadmap This document proposes how Flow should evolve from "helpful CLI + local skills" into a Codex-first control plane where the user stays inside Codex and Flow handles routing, memory, execution, and learning behind the scenes. ## Goal Target state: - the user speaks natural intent in Codex - Flow resolves references, routes workflows, fetches secure context, and runs the right tool/task - Codex sees only the smallest useful context for the current turn - repeated phrasing becomes reusable system knowledge without turning every repo preamble into a wall of rules Example desired behavior: - `document it` resolves to the docs write flow - a pasted Linear URL is unrolled before planning - `continue the last deploy investigation` finds the right session/worktree - the user does not need to remember `forge doc`, `forge linear inspect`, or repo-specific wrappers ## Problem Current Flow has strong building blocks but they are still separate: - task skills are generated and reloaded for Codex - sessions are stored and recoverable - env storage is becoming secure enough for org use - router telemetry already exists - repo-specific systems like Forge can mine aliases and inject lean workflow rules But the user still pays too much cognitive cost: - wrappers like `L` and repo-specific launchers carry logic outside Flow - repo preambles grow whenever a new shortcut is taught - skill learning is mostly manual - URL/reference unrolling is repo-specific instead of generic - Codex app-server connections are process-per-query in some paths The result is "good pieces, weak control plane". ## Design Principles 1. Flow is the control plane; repo tools remain domain executors. 2. Skills stay thin; runtime resolution carries the real behavior. 3. Reference unrolling is deterministic first, model-assisted only if needed. 4. Learning produces suggestions, not prompt bloat. 5. No default context should be paid for behavior that is not active. ## Existing Flow Building Blocks - task-synced Codex skill metadata in [src/skills.rs](/Users/nikitavoloboev/code/flow/src/skills.rs#L378) and [src/skills.rs](/Users/nikitavoloboev/code/flow/src/skills.rs#L443) - Codex skill cache reload in [src/skills.rs](/Users/nikitavoloboev/code/flow/src/skills.rs#L1224) - configurable Codex wrapper transport in [src/commit.rs](/Users/nikitavoloboev/code/flow/src/commit.rs#L5414) - multi-provider session recovery and copy flows in [src/ai.rs](/Users/nikitavoloboev/code/flow/src/ai.rs#L1) - router telemetry hooks in [src/rl_signals.rs](/Users/nikitavoloboev/code/flow/src/rl_signals.rs#L307) - current Codex session resolver direction in [codex-openai-session-resolver.md](/Users/nikitavoloboev/code/flow/docs/codex-openai-session-resolver.md#L1) These are enough to start. The missing work is unification. ## Constraint: no Codex fork Flow should target current upstream Codex directly. That means: - prefer wrapper transport + config over patching Codex - use stable upstream surfaces like normal user skill roots, `skills/list`, and `thread/*` - treat newer upstream features such as `skills/list perCwdExtraUserRoots` and in-process app-server clients as accelerators, not prerequisites - keep repo-specific behavior in Flow or repo executors, not in a private Codex fork ## Proposed Architecture ### 1. warm Codex control layer Add a Flow-managed warm control layer, either as an extension of `ai-taskd`, a focused `codexd`, or a lighter in-process broker where that is enough for the current upstream Codex client surface. Responsibilities: - maintain repo-scoped Codex app-server sessions - cache recent threads, active skills, and repo metadata - expose fast local RPC for lookup, runtime-skill injection, and doctor output - resolve references before they reach Codex as plain text - own the "what extra context is actually needed for this turn?" decision This should absorb behavior that currently lives in wrappers like `L`. ### 2. Intent registry Promote Forge-style phrase aliasing into Flow as a generic feature. Each intent has: - canonical name - phrase aliases - optional repo/path scope - resolver/action target - confidence policy - evidence counters for suggested future aliases Examples: - `doc-it` - `linear-reference` - `session-recover` - `review-intent-comment` Intent matching must stay deterministic and cheap. ### 3. Reference resolvers Flow should ship a generic resolver layer for pasted references: - Linear issue URLs - Linear project URLs - GitHub PR / issue URLs - repo file paths - commit SHAs - saved Flow session names or IDs Resolvers return structured payloads, not prose. Repo-local executors like Forge can register resolver commands for domain-specific expansion. ### 4. Runtime skills Split Codex knowledge into two layers: - baseline skills: always available, minimal repo guidance - runtime skills: ephemeral, injected only when a matched intent or resolver requires them Examples: - user says `document it` - inject tiny docs-routing runtime skill - user pastes a Linear URL - inject tiny linear-unrolled runtime context - user asks to recover recent work - inject session-recovery runtime context only for that request Runtime skills should expire automatically and be bounded by a strict budget. ### 5. Suggestion loop, not self-bloating memory Use router telemetry plus transcript mining to propose: - new aliases - new reference patterns - candidate runtime skills - stale skills that should be removed Important: - do not auto-install every observed phrase - require evidence thresholds - prefer suggested changes that collapse multiple variants into one canonical intent ## Flow Commands Add a small command family around the new control plane: ```bash f codex open [query] f codex resolve "<text-or-url>" [--json] f codex runtime f codex runtime show f codex runtime clear f codex teach suggest f codex teach accept <intent-or-suggestion-id> f codex teach reject <intent-or-suggestion-id> f codex doctor f codexd start|stop|status ``` Intended behavior: - `f codex open` replaces personal wrappers like `L` - `f codex resolve` shows what Flow would unroll or route before Codex sees it - `f codex runtime show` explains which runtime skills/context are active - `f codex teach suggest` presents evidence-backed alias/intent suggestions - `f codex doctor` exposes repo path, active app-server connection, runtime budget, skill count, and recent resolver hits ## Config Shape Proposed `flow.toml` additions: ```toml [codex] control_plane = "daemon" warm_app_server = true runtime_skill_budget_chars = 1200 auto_resolve_references = true auto_learn = "suggest-only" [codex.session] open_command = "codex" prefer_last_active = true repo_scoped_lookup = true [[codex.intent]] name = "doc-it" phrases = ["doc it", "document it", "write this down", "save this in docs"] resolver = "docs.route_write" scope = ["repo", "personal"] [[codex.intent]] name = "session-recover" phrases = ["what was i doing", "recover recent context", "continue the work"] resolver = "session.recover" [[codex.reference_resolver]] name = "linear" match = ["https://linear.app/*/issue/*", "https://linear.app/*/project/*"] command = "forge linear inspect {{ref}} --json" inject_as = "linear" [[codex.reference_resolver]] name = "docs" match = ["doc it", "document it"] command = "forge doc route --title {{title}} --json" inject_as = "docs" ``` Also add a personal/global config file for user-specific phrase preferences: - `~/.config/flow/codex-intents.toml` Use this for personal language variants that should not live in repo config. ## Daemon Responsibilities `codexd` should own: - app-server lifecycle - repo session caches - runtime skill activation/deactivation - resolver execution - secure env lookups for active workflows - bounded prompt-context assembly - suggestion generation from telemetry/history - compatibility with existing `f skills reload` and `f ai codex ...` flows It should not: - replace repo-specific executors like Forge - run opaque model-based routing in the hot path - inject large transcript summaries into every turn ## Prompt Budget Policy The runtime layer needs hard limits: - baseline repo guidance stays small - runtime additions must fit a bounded char/token budget - each resolved intent/reference should justify its own inclusion - unused runtime skills expire quickly Budget policy should prefer: 1. structured resolver output 2. one tiny runtime skill 3. one short recovery summary 4. nothing else ## Learning Loop Inputs: - router telemetry - accepted/overridden task choices - resolver hits - successful tool invocations - session transcript mining Outputs: - proposed alias additions - proposed resolver registrations - dead-skill cleanup suggestions - better default repo baselines Approval model: - repo suggestions require explicit accept - personal suggestions can default to personal scope - org/shared suggestions should stay gated ## Relationship To Forge Forge should remain the Prom executor for Prom-specific workflows. Flow should absorb the generic pieces Forge proved useful: - intent aliasing - reference unrolling - thin runtime teaching - lean docs workflow activation That means: - Prom keeps `forge linear inspect`, `forge doc`, and similar domain commands - Flow becomes the generic router that decides when to call them ## Rollout Phases ### Phase 0: unify wrappers - move `L`-style session open/recover behavior into `f codex open` - make repo-scoped Codex session resolution first-class - expose a `doctor` view for current skill/runtime state ### Phase 1: warm daemon - add `codexd` with persistent app-server connection per repo - keep recent thread cache and skills cache warm - remove process-per-query overhead for session lookup/reload paths ### Phase 2: intent registry + resolvers - add config-backed intent aliases - add generic reference resolver interface - ship built-ins for session recovery, docs routing, and Linear URLs ### Phase 3: runtime skills - inject temporary runtime skills/context instead of growing repo preambles - enforce runtime budget caps - surface active runtime state in `f codex runtime show` ### Phase 4: learning loop - mine telemetry + sessions for candidate aliases and resolver patterns - generate suggestions only after evidence thresholds - add accept/reject workflow ### Phase 5: provider expansion - reuse the same intent/resolver plane for Claude and Cursor transcript-backed workflows where useful - keep Codex as the first-class interactive target ## First Implementation Slice The highest-value first slice is: 1. `f codex open` 2. `codexd` with warm repo-scoped app-server 3. `f codex resolve` 4. config-backed intents 5. built-in resolvers for: - docs intents - Linear URLs - session recovery prompts 6. `f codex runtime show` Why this first: - it removes the most command-memory burden immediately - it uses Flow’s existing app-server + skills + session foundations - it keeps the prompt surface thin - it gives a concrete place to move personal wrapper logic ## Success Metrics - p50 `f codex open` latency - number of user prompts that required remembering a repo command - average runtime-context bytes injected per turn - resolver hit rate - accepted suggestion rate - count of active baseline skills versus runtime skills ## Non-Goals - full semantic agent routing in the hot path - unbounded transcript mining into prompt context - replacing repo executors with Flow clones - auto-learning every phrase without evidence or approval ## Summary The target system is not "more AGENTS text" and not "more commands for the user to remember". It is: - thin baseline repo guidance - a warm Flow Codex control daemon - deterministic intent/reference resolution - ephemeral runtime skills - evidence-backed learning with approval That is how Flow becomes truly Codex-first while keeping context cost low. ================================================ FILE: docs/codex-fork-tasks.md ================================================ # Codex Fork Tasks These Flow tasks automate the personal Codex fork workflow described in: - `~/docs/codex/codex-fork-home-branch-workflow.md` They are intentionally narrow: - keep `~/repos/openai/codex` as the upstream reference checkout - keep `~/repos/nikivdev/codex` as the fork home checkout - create one stable worktree per real fork task under `~/.worktrees/codex` - attach Codex sessions to that worktree instead of to one drifting checkout ## Commands Run these from `~/code/flow`: ```bash cd ~/code/flow f codex-fork-status f codex-fork-sync f codex-fork-task "add workspace ref to the footer" f codex-fork-last f codex-fork-promote --push ``` If you are outside `~/code/flow`, use the explicit config form: ```bash flow run --config ~/code/flow/flow.toml codex-fork-task "add workspace ref to the footer" ``` ## What Each Task Does ### `f codex-fork-status` Shows: - the upstream reference checkout - the personal fork home checkout - the current `nikiv`, `upstream/main`, and `private/nikiv` refs - existing fork worktrees - the last worktree used by the helper Use this first when the fork state is unclear. ### `f codex-fork-sync` Fast-forwards `nikiv` in `~/repos/nikivdev/codex` to `upstream/main`. Optional push: ```bash f codex-fork-sync --push ``` Safety rule: - it refuses to run if the fork home checkout is dirty ### `f codex-fork-task "<query>"` This is the main entry point. It: 1. derives a branch like `codex/<slug>` 2. creates or reuses `~/.worktrees/codex/<branch-name-with-slashes-rewritten>` 3. records that worktree as the "last fork worktree" 4. resumes the last Codex session in that worktree if one exists 5. otherwise starts a fresh Codex session there with an initial prompt that points at the fork workflow doc Examples: ```bash f codex-fork-task "add workspace ref to the footer" f codex-fork-task "thread name startup" --branch codex/thread-name-startup f codex-fork-task "statusline workspace ref" --no-launch ``` ### `f codex-fork-last` Resumes `codex resume --last` in the last worktree created or used by the helper. This is the closest current Flow equivalent to binding a key that reattaches to the last active fork session. You can also target a branch or path explicitly: ```bash f codex-fork-last codex/workspace-awareness f codex-fork-last ~/.worktrees/codex/codex-workspace-awareness ``` ### `f codex-fork-promote` Creates or updates a review branch from the current task worktree tip. Default mapping: - `codex/workspace-awareness` -> `review/nikiv-workspace-awareness` Examples: ```bash f codex-fork-promote f codex-fork-promote codex/workspace-awareness --push f codex-fork-promote ~/.worktrees/codex/codex-workspace-awareness --review-branch review/nikiv-codex-workspace-awareness ``` ## State File The helper records the last used worktree in: ```text ~/.flow/codex-fork/last-worktree.txt ``` That is what powers `f codex-fork-last`. ## Environment Overrides If you want the helper to point somewhere else, override these env vars: - `FLOW_CODEX_UPSTREAM_CHECKOUT` - `FLOW_CODEX_FORK_HOME` - `FLOW_CODEX_WORKTREE_ROOT` - `FLOW_CODEX_WORKFLOW_DOC` - `FLOW_CODEX_FORK_STATE_DIR` - `FLOW_CODEX_FORK_BASE_BRANCH` - `FLOW_CODEX_FORK_PRIVATE_REMOTE` - `FLOW_CODEX_FORK_UPSTREAM_REMOTE` - `FLOW_CODEX_FORK_UPSTREAM_BRANCH` ================================================ FILE: docs/codex-maple-telemetry-runbook.md ================================================ # Codex Maple Telemetry Runbook Use this when you want shared analytics for Flow-guided Codex usage without changing the local source of truth. ## What This Does Flow keeps Codex telemetry local first: - `codex/skill-eval/events.jsonl` - `codex/skill-eval/outcomes.jsonl` - Jazz2-backed Codex memory mirror Optional Maple export reads those local logs and emits a derived, redacted OTLP stream. What gets exported: - route / mode / action - runtime skill names and counts - prompt/context size metrics - reference counts - outcome kind / success - repo leaf name plus hashed path identifiers What does not get exported: - raw prompt text - full filesystem paths - raw session ids ## Configure Use Flow env store so the daemon and Flow-launched Codex sessions see the same values. For local-only secrets, prefer the personal store: ```bash cd ~/code/flow f env set --personal FLOW_CODEX_MAPLE_LOCAL_ENDPOINT=http://ingest.maple.localhost/v1/traces f env set --personal FLOW_CODEX_MAPLE_LOCAL_INGEST_KEY=maple_pk_local_xxx f env set --personal FLOW_CODEX_MAPLE_HOSTED_ENDPOINT=https://ingest.maple.dev/v1/traces f env set --personal FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY=maple_sk_hosted_xxx f env set --personal FLOW_CODEX_MAPLE_HOSTED_PUBLIC_INGEST_KEY=maple_pk_hosted_xxx ``` Optional tuning: ```bash f env set --personal FLOW_CODEX_MAPLE_SERVICE_NAME=flow-codex f env set --personal FLOW_CODEX_MAPLE_SCOPE_NAME=flow.codex f env set --personal FLOW_CODEX_MAPLE_ENV=local f env set --personal FLOW_CODEX_MAPLE_QUEUE_CAPACITY=1024 f env set --personal FLOW_CODEX_MAPLE_MAX_BATCH_SIZE=64 f env set --personal FLOW_CODEX_MAPLE_FLUSH_INTERVAL_MS=100 f env set --personal FLOW_CODEX_MAPLE_CONNECT_TIMEOUT_MS=400 f env set --personal FLOW_CODEX_MAPLE_REQUEST_TIMEOUT_MS=800 ``` On macOS, personal env reads may require a daily unlock: ```bash f env unlock ``` If you launch Codex through Flow (`j ...`, `f codex ...`, `k ...`), Flow will hydrate these `FLOW_CODEX_MAPLE_*` vars into the child Codex process. Explicit shell env vars override the personal store for that launch, which makes one-off telemetry tests quiet and deterministic. From inside a Codex session, the write path stays the same: ```bash f env set --personal FLOW_CODEX_MAPLE_HOSTED_ENDPOINT=... f env set --personal FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY=... f env get --personal FLOW_CODEX_MAPLE_HOSTED_ENDPOINT ``` ## Run Inspect current state: ```bash f codex telemetry status ``` Flush unseen local telemetry once: ```bash f codex telemetry flush --limit 200 ``` Equivalent task shortcuts: ```bash f codex-telemetry-status f codex-telemetry-flush ``` ## Background Export If `codexd` is running, it also performs a bounded background flush pass during its normal maintenance loop. This keeps export cheap and avoids a separate always-on process. ## Notes - Export is derived and best-effort. - Local logs and memory stay canonical even if Maple is unavailable. - This is intended for route/context/outcome analytics, not transcript export. ================================================ FILE: docs/codex-openai-session-resolver.md ================================================ # Codex OpenAI Repo Session Resolver This documents the personal `L` wrapper that opens or resumes Codex sessions with repo-specific query matching for `~/repos/openai/codex`. Relevant files: - fish entrypoint: `~/config/fish/fn.fish` - resolver: `~/config/fish/scripts/codex-openai-session.ts` ## Behavior - `L` with no args runs `f ai codex new`. - `L <query>` targets stored Codex sessions for `~/repos/openai/codex`. - On a successful match it runs `f ai codex resume <thread-id>` in that repo. - On no match it exits non-zero and prints a short recent-session list instead of opening the wrong conversation. ## Explicit Recovery Prompts `l` and `L` now also treat explicit recovery phrases as a separate lightweight path. Examples: - `see this convo in ...` - `what was I doing in ...` - `recover recent context` - `continue the ... work` For those prompts, the launcher first runs: ```bash f ai codex recover --summary-only --path <derived target> <prompt> ``` Then it prepends the short recovery summary to the new prompt and opens the session in the derived target repo/workspace. Normal prompts do not pay this cost. ## Why use Codex app-server This path uses `codex app-server` instead of parsing `f ai codex list` output. That matters because `thread/list` gives: - exact `cwd` filtering for `~/repos/openai/codex` - stable `updatedAt` ordering - structured fields such as `id`, `name`, `preview`, `gitInfo`, and `cwd` - pagination and optional server-side `searchTerm` For this wrapper, exact repo scoping is the main win. It avoids mixing sessions from unrelated repos and avoids depending on Flow's imported session index. ## Resolution flow 1. Spawn `codex app-server` with cwd set to `~/repos/openai/codex`. 2. Send `initialize`, then `initialized`. 3. Call `thread/list` with: - `cwd: ~/repos/openai/codex` - `archived: false` - `sortKey: updated_at` 4. Use a small first fetch when possible: - latest query: 1 - `after most recent active`: 2 - text search query: 25 - fallback scan: up to 100 5. Resolve the query against returned threads. 6. For textual queries, rerank the top shortlist with `thread/read includeTurns:true` using full turn text. - if summary fields miss completely, probe the most recent few threads by full turn text before giving up 7. Resume the chosen thread through `f ai codex resume <id>` in the Codex repo. ## Query matching rules The resolver is deterministic. It does not call a model. Matching order: 1. exact or unique thread id prefix 2. relative query with `after` or `before` 3. ordinal query such as `2`, `second`, `3rd` 4. text ranking across: - `thread.name` - `thread.preview` - `thread.gitInfo.branch` - `thread.gitInfo.sha` 5. pure recency query such as `most recent active` Examples: - `L most recent active` - `L session after most recent active` - `L second` - `L 019cca91` - `L where does codex store` - `L history.jsonl` Important accuracy guardrails: - `last` only means "latest session" when the rest of the query is otherwise empty after control words are removed. - bare numbers only become ordinals when the query reduces to just that number. - directional queries stay directional. If there is no next or previous match, the resolver fails instead of silently falling back to the latest session. ## Current limits - It starts a fresh `codex app-server` process on every lookup. That is the main latency cost. - `thread/list searchTerm` only filters extracted titles and is case-sensitive, so the helper still needs local fallback ranking. - The second pass only reads a few candidate threads, so this is still a bounded heuristic rather than a full semantic search across all history. - Relative anchor queries are strongest for latest or ordinal anchors and weaker for arbitrary natural-language anchors. ## Best improvements ### Speed - Keep a long-lived local resolver daemon so `L` reuses one app-server connection instead of spawning a fresh process. - Add a small local cache keyed by repo path with `id`, `updatedAt`, `name`, `preview`, and `gitInfo`, then refresh it opportunistically. - If Codex exposes a stable reusable host transport beyond stdio for local clients, switch to that instead of process-per-query. ### Accuracy - Start naming important sessions with `thread/name/set`; exact names will beat fuzzy preview matching. - If the wrapper ever controls session creation, persist higher-signal naming or metadata early instead of inferring from preview text later. - Extend the second pass to handle arbitrary textual anchors inside `after ...` and `before ...` queries more deeply when the anchor match is still weak. ### Prompting A model-based resolver is possible but should be the fallback, not the first pass. Why: - slower than deterministic matching - easier to silently choose the wrong session - unnecessary when `id`, `updatedAt`, `name`, `preview`, and repo path already narrow the space well If a model is added later, the safe shape is: - deterministic shortlist first - prompt only over the top few candidates - require the model to return one thread id or `NONE` - keep strict failure if confidence is weak ## Best next step The highest-value next change is reducing lookup latency: 1. keep one local long-lived resolver or daemon 2. reuse one app-server connection per repo 3. cache recent shortlist results and refresh opportunistically That should improve the user-visible speed more than further prompt tuning. ================================================ FILE: docs/commands/ai.md ================================================ # f ai Manage Claude Code, Codex, and Cursor sessions for the current project. Flow reads local session stores, filters by current project path, and gives you one interface for list/search/resume/copy/save. When you need to reopen a repo's session from another working directory, use `--path <project-root>` on `resume` or provider-specific `continue`. Cursor transcripts are supported for reading only. ## Quick Start ```bash # Fuzzy-pick a recent session (Claude + Codex + Cursor) f ai # Provider-specific list/read f ai claude list f ai codex list f ai codex sessions f ai codex sessions --path ~/repos/mark3labs/kit f ai cursor list f codex resolve "latest" f codex resolve "https://linear.app/.../project/.../overview" --json f codex open "continue the deploy work" f ai claude resume <session-id-or-name> f ai codex resume <session-id-or-name> f ai codex resume --path ~/work/example-project f ai codex continue --path ~/work/example-project f ai cursor context - /path/to/repo 3 f cursor copy # Save a memorable alias for a session f ai save reclaim-fix --id a38cf8bf-f4e2-4308-8b27-0254f89c4385 ``` ## Session Sources - Claude: `~/.claude/projects/<project-path>/*.jsonl` - Codex: `~/.codex/sessions/**/*.jsonl` (Flow matches by `session_meta.cwd`) - Cursor: `~/.cursor/projects/<project-key>/agent-transcripts/<session-id>/<session-id>.jsonl` - Saved aliases: `.ai/sessions/claude/index.json` in your repo ## Resume Behavior (Important) ### TTY requirement Resume is interactive by design. - `f ai claude resume ...` requires a terminal TTY. - `f ai codex resume ...` requires a terminal TTY. - In non-interactive shells, Flow exits with a clear error and non-zero status. - Cursor does not currently expose a Flow resume/continue path; use `list`, `copy`, or `context`. ### Claude exact-ID behavior If you pass an explicit session (`name`, `id`, or `id-prefix`), Flow is strict: - it attempts `claude --resume <id>` - if Claude cannot open that exact session, Flow fails - Flow does **not** auto-fallback to `--continue` for explicit sessions (prevents opening the wrong conversation) ### Claude no-arg behavior For `f ai claude resume` with no argument: - Flow picks most recent Claude session for this project - if that resume fails, Flow may fallback to `claude --continue` in the same cwd (TTY only) ### Codex behavior For Codex, Flow runs: ```bash codex resume <id> --dangerously-bypass-approvals-and-sandbox ``` No fallback is applied on resume failure; Flow returns non-zero. ### Codex open and resolve Use `open` when you want one Codex entrypoint that stays conservative about context: ```bash f codex open f codex open "continue the deploy work" f codex open "resume latest" f codex open --path ~/work/example-project "what was I doing here" f codex resolve "https://linear.app/fl2024008/project/llm-proxy-v1-6cd0a041bd76/overview" --json f codex doctor --path ~/work/example-project ``` Behavior: - no query: start a fresh Codex session in the target repo - explicit session lookup queries like `latest`, `resume session`, ordinals, or session IDs: resume the matching Codex session - explicit recovery prompts like `what was I doing` or `continue the ... work`: build a compact recovery handoff and start a new session - matching reference resolvers: inject only compact resolver output, then append the user request - otherwise: start a new session with the raw query and no extra wrapper text This keeps prompt cost flat unless Flow has a strong reason to recover or unroll context. Use `f codex doctor` to confirm whether wrapper transport, runtime skills, and context budgets are actually active for the current repo. ### Codex sessions after a crash or restart If your Mac restarts and you lose the live Codex terminals, use: ```bash f ai codex sessions f ai codex sessions --path ~/repos/mark3labs/kit ``` Behavior: - lists recent Codex sessions for the current path - sorts by the last message timestamp descending - shows the stable session id plus a preview of the latest message - the numeric index matches `continue`, so you can reopen quickly Examples: ```bash f ai codex continue 1 --path ~/repos/mark3labs/kit f ai codex continue 019cd046 --path ~/repos/mark3labs/kit f ai codex sessions --path ~/repos/mark3labs/kit --json ``` ### Optional `flow.toml` resolver config You can teach `f codex open` and `f codex resolve` to unroll repo-specific references: ```toml [codex] auto_resolve_references = true prompt_context_budget_chars = 900 max_resolved_references = 1 [[codex.reference_resolver]] name = "linear" match = ["https://linear.app/*/issue/*", "https://linear.app/*/project/*"] command = "my-linear-tool inspect {{ref}} --json" inject_as = "linear" ``` Notes: - configure this in repo `flow.toml` or global `~/.config/flow/flow.toml` - `{{ref}}`, `{{query}}`, and `{{cwd}}` are available in resolver commands - built-in Linear URL parsing works even without a custom resolver - resolver output is compacted before prompt injection - `prompt_context_budget_chars` hard-caps injected context before your request is appended - `max_resolved_references` prevents broad unrolling from bloating one turn ### Optional runtime skill transport Flow can also materialize tiny per-launch runtime skills for current upstream Codex without forking Codex. Enable it globally with: ```bash f codex enable-global --full f codex doctor --path ~/docs --assert-runtime --assert-schedule ``` Or configure it manually with: ```toml [codex] runtime_skills = true [options] codex_bin = "~/code/flow/scripts/codex-flow-wrapper" ``` Current first-slice behavior: - `f codex open "write plan"` can attach a tiny plan-writing runtime skill - the runtime skill is exposed only for the launched Codex process - Flow keeps the generated runtime state under `~/.config/flow/codex/runtime` Inspect or clear runtime state: ```bash f codex runtime show f codex runtime clear f codex memory status f codex memory query --path ~/code/flow "codex control plane runtime skills" f codex memory recent --path ~/docs f codex doctor ``` Assertive health checks: ```bash f codex doctor --path ~/docs --assert-runtime f codex doctor --path ~/docs --assert-schedule f codex doctor --path ~/docs --assert-learning f codex doctor --path ~/docs --assert-autonomous ``` `--assert-learning` is intentionally strict: it fails until Flow has real logged events, grounded outcome samples, and a non-empty scorecard for that target. Built-in plan writer: ```bash cat <<'EOF' | f codex runtime write-plan --title "Example Plan" # Example Plan - item EOF ``` ### Skill eval and background refresh Flow can learn which runtime skills are actually worth injecting from local Codex usage history without replaying Codex in the hot path. Useful commands: ```bash f codex eval --path ~/work/example-project f codex memory sync --limit 400 f codex memory recent --path ~/work/example-project --limit 12 f codex skill-eval show --path ~/work/example-project f codex skill-eval run --path ~/work/example-project f codex skill-eval cron --limit 400 --max-targets 12 --within-hours 168 f codex telemetry status f codex telemetry flush --limit 200 f codex trace status f codex trace current-session --json f codex trace inspect <trace-id> --json f codex skill-source list --path ~/work/example-project f codex skill-source sync --path ~/work/example-project --skill find-skills ``` The Codex memory mirror: - stores durable indexed memory under the Jazz2 root (`~/.jazz2/...` or `~/repos/garden-co/jazz2/.jazz2/...`) - mirrors Flow’s route/outcome history into SQLite with WAL enabled - extracts compact repo/code facts from repo capsules (summary, commands, important paths, docs hints) - adds bounded live code-path retrieval for explicit repo references, so prompts like `see ~/code/flow ...` can inject likely files such as `src/ai.rs` or `docs/...` without dumping raw source - indexes durable repo entrypoints and extracted symbols under the same Jazz2-rooted memory store, then supplements them with live symbol extraction from the top-ranked code files during `memory query` / `codex resolve` - adds tiny symbol snippets for the top code hits, so coding prompts can carry actual struct/function shape without inlining whole files - biases retrieval by intent: implementation/file-edit prompts prefer symbols, snippets, and `src/...` paths; summary/docs prompts prefer doc headings and docs paths - stays best-effort so failed memory writes do not block normal Codex coding turns - is refreshed again by `f codex skill-eval cron`, so the mirror heals even if a hot-path write is skipped - is queried automatically for explicit repo references during `f codex open` / `f codex resolve` What `cron` does: - scans only recent logged Flow Codex events - syncs recent skill-eval logs into the Jazz2-backed memory mirror - skips missing/moved repo paths - rebuilds scorecards for a bounded number of recent repos - never launches Codex or replays network work in the background For your use case, this keeps learning cheap and safe enough to run regularly. ### Trace inspection for Flow-managed Codex sessions Flow now assigns a trace envelope to all Flow-managed Codex launches, not just special workflows like `check <github-pr-url>`. That means sessions started or resumed through: ```bash j ... k <session-id> f codex open ... f codex resume ... f codex continue ... ``` carry `FLOW_TRACE_*` env vars and emit at least one compact Flow telemetry event, so the session becomes remotely inspectable. Useful commands: ```bash f codex trace status f codex trace current-session --json f codex trace inspect <trace-id> --json ``` Behavior: - `trace status` checks whether Maple MCP reads are configured and reachable - `trace current-session` reads the active `FLOW_TRACE_ID` from the current Flow-managed Codex session, flushes recent Flow telemetry once, then attempts a remote trace read - if Maple reads are partially configured but tenant access is still blocked, Flow returns the current trace metadata plus `readError` instead of failing without context This keeps the workflow autonomous from inside Codex itself: once the session was started through Flow, you can inspect the current trace without manually copying ids. `f codex eval --path ...` is the joined operator report: - current runtime/doctor state - recent route mix and context cost - grounded skill scorecard highlights - concrete “what to improve next” recommendations - commands to deepen or fix the current state If `codexd` is running, it also keeps recent completion reconciliation warm and now does a bounded background scorecard refresh, so the eval report does not go stale as quickly between manual runs. Optional telemetry export follows the same local-first pattern: Flow keeps local Codex logs canonical, and if `FLOW_CODEX_MAPLE_*` env vars are set it can export redacted route/context/outcome spans to Maple without shipping raw prompts or full repo paths. Use: ```bash f codex telemetry status f codex telemetry flush --limit 200 ``` For local-only secrets, prefer `f env set --personal ...`. On macOS you may need `f env unlock` once per day for background reads. Flow-launched Codex sessions also inherit the same `FLOW_CODEX_MAPLE_*` values from the personal store, while explicit shell env still wins for one-off tests. When `codexd` is running, the daemon also performs a bounded background export pass so the external analytics view stays warm. ### macOS launchd schedule for skill-eval If you want scorecards to stay fresh automatically on macOS: ```bash f codex-skill-eval-launchd-install f codex-skill-eval-launchd-status f codex-skill-eval-launchd-logs ``` `f codex enable-global --full` installs this schedule for you. Default schedule: - every 30 minutes - scan up to 400 recent events - rebuild up to 12 recent repo scorecards - ignore repos not seen in the last 168 hours You can tune install-time bounds: ```bash f codex-skill-eval-launchd-install --minutes 20 --limit 600 --max-targets 16 --within-hours 72 f codex-skill-eval-launchd-install --dry-run ``` Remove it with: ```bash f codex-skill-eval-launchd-uninstall ``` ### Cursor behavior Cursor transcripts are read-only in Flow: - `f ai cursor list` opens a picker and copies the selected transcript - `f ai cursor copy` copies the latest Cursor transcript for this repo - `f ai cursor context ...` copies the last N exchanges - `f cursor ...` is a shortcut for the same provider-specific read commands ### Cross-directory resume You can target another repo without changing directory: ```bash f ai codex resume --path ~/work/example-project f ai codex resume --path ~/work/example-project 019c61c5-0aef-71a1-b058-5c9ab43013d4 f ai codex continue --path ~/work/example-project ``` - `resume --path <repo>` resolves the requested session against that repo instead of the current cwd - `continue --path <repo>` resumes the latest session for that repo - explicit full Codex IDs still work directly even when your current cwd is different ## Session Selectors `resume` accepts: - saved alias from `.ai/sessions/claude/index.json` - full session ID - ID prefix (8+ chars) - numeric index from list output (1-based) Examples: ```bash f ai resume my-feature f ai resume a38cf8bf f ai claude resume 2 f ai codex resume 019c61c5-0aef-71a1-b058-5c9ab43013d4 f ai cursor context 382ef1a3 /path/to/repo 2 ``` ## Content Copy Commands ```bash # Copy full conversation to clipboard f ai copy f ai copy <session> # Copy last N prompt/response turns f ai context f ai context <session> <path> <count> ``` Use `-` as session placeholder to trigger fuzzy selection: ```bash f ai claude context - /path/to/repo 3 f ai cursor context - /path/to/repo 3 ``` ## Project Workflow (Recommended) 1. Start from repo root and inspect tasks: `f tasks list` 2. Resume exact session when continuing prior work: `f ai claude resume <id>` or `f ai codex resume <id>` 3. Keep context current: `f skills sync` then `f skills reload` 4. Validate through tasks: `f test-related` / `f test` 5. Commit through Flow gates: `f commit` This keeps sessions, tasks, skills, and commit quality checks in one loop. ## Everruns Bridge Mode Flow also supports running a prompt through Everruns while routing client-side `seq_*` tool calls to local `seqd`: ```bash f ai everruns "open Safari and take a screenshot" ``` Key points: - This path is additive. It does not replace `f ai claude ...` or `f ai codex ...`. - Flow now reuses Seq's canonical Everruns bridge for: - `seq_*` tool definitions injected into new sessions - tool-name normalization (`seq_open_app`, `seq.open_app`, `seq:open-app`) - request correlation IDs (`request_id`, `run_id`, `tool_call_id`) - Event transport is SSE-first (`/sse`) with automatic fallback to polling (`/events`) if SSE is unavailable. - Optional Maple telemetry export can dual-write runtime traces to local + hosted ingest endpoints when `SEQ_EVERRUNS_MAPLE_*` env vars are configured. - Existing Flow features remain unchanged (`f seq-rpc`, session resume/copy/context flows). Setup and validation details are documented in: - `docs/everruns-seq-bridge-integration.md` - `docs/everruns-maple-runbook.md` ================================================ FILE: docs/commands/clone.md ================================================ # f clone Clone a repository with git-like destination behavior. ## Overview `f clone` behaves like `git clone` for destination paths: - `f clone <url>` clones into the current working directory using Git's default folder naming. - `f clone <url> <dir>` clones into an explicit destination directory. For GitHub inputs, Flow normalizes clone URLs to SSH: - `https://github.com/owner/repo` -> `git@github.com:owner/repo.git` - `owner/repo` -> `git@github.com:owner/repo.git` This command does not force clones into `~/repos` and does not auto-configure `upstream`. ## Usage ```bash f clone <url-or-owner/repo> [directory] ``` ## Examples ```bash # GitHub URL -> SSH clone URL f clone https://github.com/genxai/new # owner/repo shorthand -> SSH clone URL f clone genxai/new # explicit destination folder (same as git clone) f clone genxai/new my-local-new ``` ## When To Use - Use `f clone` when you want standard `git clone` destination behavior. - Use [`f repos clone`](repos.md) when you want managed placement under `~/repos/<owner>/<repo>` plus optional upstream automation. ================================================ FILE: docs/commands/commit.md ================================================ # f commit AI-powered commit with deferred Codex review and GitEdit sync. ## Overview Stages all changes, commits quickly by default, runs Codex deep review asynchronously in the background, pushes, and syncs AI sessions to gitedit.dev. ## Quick Start ```bash # Default flow: commit now, Codex review runs in background f commit # Blocking pre-commit review (legacy behavior) f commit --slow # Fast commit with custom message (no AI review, no Codex follow-up) f commit --fast "fix typo" f commit --fast # defaults to "." as message # Queue for review (no push) + create jj review bookmark f commit --queue # Commit without pushing f commit -n # Include AI context in review f commit --context # Custom message f commit -m "Fixes #123" ``` ## Options | Option | Short | Description | |--------|-------|-------------| | `--no-push` | `-n` | Skip pushing after commit | | `--queue` | | Queue the commit for review (no push) | | `--no-queue` | | Bypass queue and allow push | | `--sync` | | Run synchronously (don't delegate to hub) | | `--context` | | Include AI session context in code review | | `--dry` | | Dry run: show context without committing | | `--quick` | | Explicitly use fast commit + async Codex review (compat alias) | | `--slow` | | Run blocking pre-commit review before commit | | `--fast [MSG]` | | Fast commit with no AI review (defaults to ".") | | `--codex` | | Use Codex instead of Claude for review | | `--review-model <MODEL>` | | Choose specific review model | | `--message <MSG>` | `-m` | Custom message appended to commit | | `--tokens <N>` | `-t` | Max tokens for AI context (default: 1000) | ## Review Models | Model | Description | |-------|-------------| | `claude-opus` | Claude Opus 1 for review (default) | | `codex-high` | Codex high-capacity (gpt-5.1-codex-max) | | `codex-mini` | Codex mini (gpt-5.1-codex-mini) | ```bash # Use Codex f commit --codex # Specific model f commit --review-model codex-high ``` --- ## Fast Commit + Deferred Codex Review `f commit` now uses this mode by default. ### Default (`f commit`) / `--quick` — commit now, Codex reviews later Commits immediately (generates an AI commit message but skips the blocking code review), then spawns a background Codex review for that commit. The review result is queued and visible via `f commit-queue list`. ```bash f commit f commit --quick ``` What happens: 1. Stages all changes, generates commit message, commits, pushes 2. Queues the commit SHA for async review 3. Spawns a background Codex process that reviews the diff 4. You keep working — check results later with `f commit-queue list` ### `--slow` — run blocking review before commit Runs the pre-commit AI review before creating the commit. ```bash f commit --slow f commit --slow --review-model codex-high ``` ### `--fast` — instant commit, no review at all Commits with a provided message (or `"."` if omitted). No AI review, no async follow-up. Useful for trivial changes. ```bash f commit --fast "fix typo" f commit --fast # message defaults to "." ``` ### When to use which | Flag | AI message | Code review | Async review | Best for | |------|-----------|-------------|-------------|----------| | (none) | Yes | No | Yes (Codex background) | Default fast workflow | | `--quick` | Yes | No | Yes (Codex background) | Explicit fast mode | | `--slow` | Yes | Yes (blocking) | No | Pre-commit deep check | | `--fast` | No (you provide) | No | No | Trivial/WIP commits | ### Opt Out Of Fast Default If you want plain `f commit` to run blocking review by default, set: ```toml [commit] quick-default = false ``` With this set, plain `f commit` behaves like `f commit --slow` unless you explicitly pass `--quick`. Run deep review in batches: ```bash f reviews-todo codex --all f reviews-todo list ``` ### Myflow Fast + Deep Profile For `~/code/myflow`, this profile gives a fast default loop while keeping deep Codex coverage: ```toml [commit] quick-default = true queue = false queue_on_issues = false # Fast message generation path via zerg/ai (glm + cerebras), then fallbacks. message_fallbacks = [ "rise:zai:glm-5", "rise:cerebras:gpt-oss-120b", "remote", "openai" ] # When you explicitly run blocking review, prefer fast models first. review_fallbacks = [ "glm5", "rise:cerebras:gpt-oss-120b", "codex-high" ] ``` Then operate with: ```bash f commit f reviews-todo codex --all ``` --- ## What Happens 1. **Safety Checks** - Warns about sensitive files (.env, .pem, keys, credentials) - Warns about files with large diffs (500+ lines) - Runs invariant checks from `[invariants]` when configured 2. **Stage Changes** - Runs `git add -A` to stage all changes 3. **Code Review** - Sends diff to AI for review - Checks for bugs, security issues, best practices - Optionally includes AI session context (`--context`) 4. **Generate Message** - AI generates commit message from diff - Appends custom message if provided (`-m`) 5. **Commit** - Creates commit with generated message 6. **Queue (optional)** - Adds commit to the review queue (`f commit-queue list`) - Creates a jj review bookmark (e.g., `review/main-<sha>`) 7. **Push** - Pushes to remote (unless `--no-push` or `--queue`) 7. **GitEdit Sync** - Syncs AI session data to gitedit.dev ## Invariant Gate If your project defines `[invariants]` in `flow.toml`, `f commit` evaluates the staged diff against those rules (forbidden patterns, dependency allowlist policy, file line limits). - `mode = "warn"`: commit continues and findings are shown. - `mode = "block"`: commit is blocked on warning/critical findings. - Findings are injected into the AI review context. --- ## Usage Examples ### Basic Commit ```bash # Review, commit, and push f commit ``` ### Local Commit Only ```bash # Don't push to remote f commit -n ``` ### With AI Context Include recent AI session context in the review: ```bash f commit --context f commit --context --tokens 2000 # More context ``` ### Custom Message Append additional context to the commit: ```bash f commit -m "Fixes #123" f commit -m "Co-authored-by: John <john@example.com>" ``` ### Dry Run See what would be reviewed without committing: ```bash f commit --dry ``` ### Synchronous Mode Run directly without delegating to hub: ```bash f commit --sync ``` --- ## Safety Warnings ### Sensitive Files Before committing, flow warns if staging files that look sensitive: ``` ⚠ Warning: The following sensitive files are staged: - .env - credentials.json - private.key ``` Sensitive patterns include: - `.env`, `.env.*` - `credentials.json`, `secrets.json` - `.pem`, `.key`, `id_rsa`, `id_ed25519` - Files containing `password`, `secret`, `token` ### Secret Scan (Staged Diff) Flow scans staged diffs for likely secrets (API keys, tokens, passwords). If a match is detected, the commit is blocked. In an interactive terminal, flow offers to run an auto-fix using `ai` to mask or replace the values, then asks for approval to continue. You can bypass the check for a single commit with: ``` FLOW_ALLOW_SECRET_COMMIT=1 f commit ``` ### Large Diffs Warns about files with significant changes: ``` ⚠ Warning: The following files have large diffs: - src/generated.rs (1523 lines) - data/fixtures.json (834 lines) ``` Threshold: 500+ lines added/removed. --- ## Related Commands | Command | Description | |---------|-------------| | `f commit` | Fast commit + deferred Codex review (default) | | `f commitWithCheck` | Review without GitEdit sync | | `f commitSimple` | No review, just AI commit message | ### commitWithCheck (alias: cc) Same as `commit` but skips GitEdit sync: ```bash f commitWithCheck f cc # Short alias ``` ### commitSimple Quick commit without code review: ```bash f commitSimple ``` --- ## Configuration ### Hub Delegation By default, `f commit` delegates to the hub daemon for async processing. Use `--sync` for direct execution: ```bash # Async via hub (default) f commit # Sync (direct) f commit --sync ``` ### Commit Message Tool Optionally force a specific commit-message generator: ```toml [commit] message_tool = "kimi" # also supports: claude, rise, glm5, opencode, openrouter, remote, openai message_model = "kimi-k2-thinking-turbo" # optional, tool-specific ``` If the forced tool fails, `f commit` now falls back through the configured/default chain. ### Review Tool (Kimi CLI) Use Kimi Code CLI for code review: ```toml [review] tool = "kimi" model = "kimi-k2-thinking-turbo" # optional; uses Kimi default if omitted ``` This uses `kimi --quiet` (print mode) with your existing Kimi CLI auth/config. ### Robust Fallbacks `f commit` now uses multi-attempt fallback chains for both review and commit-message generation. Default behavior: - Review: primary selection, then `openrouter`, `claude`, `codex-high` - Message: review-aligned/override tool, then `remote` (myflow), `openai`, `openrouter` - If all message attempts fail and fail-open is enabled, Flow uses a deterministic local fallback message. Configuration: ```toml [commit] review_fail_open = true message_fail_open = true review_fallbacks = ["openrouter", "claude", "codex-high"] message_fallbacks = ["remote", "openai", "openrouter"] ``` Environment overrides: - `FLOW_COMMIT_REVIEW_FAIL_OPEN=0|1` - `FLOW_COMMIT_MESSAGE_FAIL_OPEN=0|1` - `FLOW_COMMIT_REVIEW_FALLBACKS="openrouter,claude,codex-high"` - `FLOW_COMMIT_MESSAGE_FALLBACKS="remote,openai,openrouter"` Codex -> GLM5 fallback example: ```toml [commit] review_fallbacks = ["glm5", "openrouter", "claude"] message_fallbacks = ["glm5", "remote", "openai"] ``` `glm5` maps to the Rise/internal route with model `zai:glm-5`. ### Queue Policy Queue only when review finds issues (auto-push on a clean review): ```toml [commit] queue = true queue_on_issues = true ``` `--queue` / `--no-queue` still override this behavior. ### Commit Quality Gates (Testing + Required Skills) Use local gates to block commits that skip tests or required workflow skills: ```toml [commit.testing] mode = "block" runner = "bun" bun_repo_strict = true require_related_tests = true ai_scratch_test_dir = ".ai/test" run_ai_scratch_tests = true allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 20 [commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] quality-bun-feature-delivery = 2 ``` When `mode = "block"`, `f commit` fails until the test/skill requirements are satisfied. For Bun repos, run checks with `bun bd test ...` for debug-build validation. If no related tracked tests are found, Flow can run tests under `ai_scratch_test_dir` (default `.ai/test`) as a fallback signal. ### AI Session Context When `--context` is enabled, includes recent Claude Code session context: ```bash # Default: 1000 tokens of context f commit --context # More context f commit --context --tokens 3000 ``` --- ## Examples ### Quick Bug Fix ```bash # Fix bug, commit with context vim src/lib.rs f commit --context -m "Fixes null pointer in edge case" ``` ### Feature Branch ```bash # Work on feature git checkout -b feature/new-api # ... make changes ... # Commit without push f commit -n # More changes... f commit -n # Final push git push -u origin feature/new-api ``` ### Review Before Commit ```bash # See what would be reviewed f commit --dry # If satisfied, commit f commit ``` --- ## Troubleshooting ### "Sensitive files staged" Either: 1. Add files to `.gitignore` 2. Unstage with `git reset HEAD <file>` 3. Proceed anyway if intentional ### Review taking too long Use `--sync` to see progress directly: ```bash f commit --sync ``` ### Hub not responding Fall back to sync mode: ```bash f commit --sync ``` ## See Also - [upstream](upstream.md) - Sync forks before committing - [publish](publish.md) - Publish after committing ================================================ FILE: docs/commands/commits.md ================================================ # f commits Browse commits with AI metadata and mark notable commits for quick access. ## Usage ```bash f commits f commits --limit 200 f commits --all f commits top f commits mark <hash> f commits unmark <hash> ``` ## Notable commits Notable commits are stored in `.ai/internal/commits/top.txt` in the repo root. Each line is: ``` <full-hash>\t<label> ``` ## Key bindings When using fzf: - `ctrl-t` — toggle notable for the selected commit. ================================================ FILE: docs/commands/db.md ================================================ # db Manage database workflows and backends (Jazz + Postgres). ## Usage ```bash f db <provider> <action> ``` ## Jazz Create Jazz2 app credentials and populate env vars for the current project. ```bash f db jazz new --kind mirror --name gitedit-mirror ``` Resolution order for the credential bootstrap helper: 1. Local `jazz-tools` binary on `PATH` 2. Pinned `npx --yes jazz-tools@0.20.14` For deliberate experiments, override the pinned fallback with: ```bash FLOW_JAZZ_TOOLS_PACKAGE_SPEC=jazz-tools@<version> f db jazz new --kind mirror ``` ## Postgres Run Drizzle migrations for the default Postgres project (`~/org/la/la/server`). ```bash f db postgres migrate f db postgres migrate --generate f db postgres generate ``` Environment resolution order for `DATABASE_URL`: 1. `--database-url` flag 2. `DATABASE_URL` 3. `PLANETSCALE_DATABASE_URL` / `PSCALE_DATABASE_URL` 4. `<project>/.env` (DATABASE_URL) Use `--project` to override the Postgres project directory. ================================================ FILE: docs/commands/deploy.md ================================================ # f deploy Deploy projects to hosts and cloud platforms. ## Overview The `deploy` command handles deployment to multiple platforms: - **Linux hosts** via SSH (with systemd + nginx) - **Cloudflare Workers** - **Railway** Auto-detects the platform from your `flow.toml` configuration. If `[flow].deploy_task` is set, `f deploy` runs that task first. If no deployment config exists but a `deploy` task is defined, `f deploy` runs that task. Use `f prod` to deploy directly from `[host]`, `[cloudflare]`, `[railway]`, or `[web]` (it skips `[flow].deploy_task`). ## Quick Start ```bash # Auto-deploy based on flow.toml config f deploy # Production deploy (skips flow.deploy_task, uses deploy config or deploy-prod task) f prod # Run the project's release task (flow.release_task or fallback) f deploy release # Deploy to specific platform f deploy host f deploy cloudflare f deploy railway # Production deploy to specific platform f prod host f prod cloudflare # Configure deployment defaults f deploy config # Deploy web site f deploy web ``` ## Subcommands | Command | Alias | Description | |---------|-------|-------------| | `host` | `h` | Deploy to Linux host via SSH | | `cloudflare` | `cf` | Deploy to Cloudflare Workers | | `web` | | Deploy the web site (Cloudflare) | | `setup` | | Interactive deploy setup (Cloudflare) | | `railway` | | Deploy to Railway | | `config` | | Configure deployment defaults (Linux host) | | `release` | | Run the project's release task | | `status` | | Show deployment status | | `logs` | | View deployment logs | | `restart` | | Restart the deployed service | | `stop` | | Stop the deployed service | | `shell` | | SSH into the host | | `set-host` | `set` | Configure host for deployment | | `show-host` | | Show current host configuration | | `health` | | Check if deployment is healthy | --- ## Web Deployment Deploys the web site using Cloudflare and your project tasks. Flow will: - Ensure `[web]` exists in `flow.toml` (auto-fills `path` when possible). - Add the `web.route` (or `web.domain/*`) to your wrangler config. - Optionally create/update the Cloudflare DNS record for the domain/route. - Apply env vars from cloud if `web.env_source = "cloud"` is set. - Run `deploy-web` (or `deploy` as fallback). ```bash f deploy web ``` If the Cloudflare API token is missing, Flow will guide you to create one and store it in your env store as `CLOUDFLARE_API_TOKEN`. If `web.domain`/`web.route` or `web.path` is missing, Flow will prompt for them and update `flow.toml`. If you opt into DNS management, Flow will prompt for record type/target and create or update the Cloudflare DNS record (default: `A` to `192.0.2.1`, proxied). If cloud is unavailable, Flow can store envs locally when you choose `web.env_source = "local"`. Example `flow.toml`: ```toml [web] path = "packages/web" domain = "example.com" env_source = "cloud" env_apply = "always" env_keys = ["PUBLIC_API_URL"] env_vars = ["PUBLIC_API_URL"] ``` ## Host Deployment (Linux via SSH) Deploy to any Linux server with SSH access. Flow handles: - File syncing via rsync - Systemd service creation - Nginx reverse proxy setup - SSL via Let's Encrypt ### Configuration Add to `flow.toml`: ```toml [flow] deploy_task = "deploy-cli-release" [host] dest = "/opt/myapp" # Remote destination path run = "./server" # Command to run the service port = 3000 # Port the service listens on service = "myapp" # Systemd service name (optional, defaults to folder name) setup = "./scripts/setup.sh" # Setup script to run after first sync (optional) env_file = ".env.production" # Path to .env file for secrets (optional) env_source = "flow" # Pull envs from Flow env store (optional) env_keys = ["API_KEY"] # Keys to fetch when env_source=flow/cloud (optional) domain = "myapp.example.com" # Public domain for nginx (optional) ssl = true # Enable SSL via Let's Encrypt (optional) ``` Tip: `f setup deploy` can scaffold the `[host]` section and create a remote setup script. ### Setup Host First, configure your SSH connection: ```bash # Set host (stored globally at ~/.config/flow/deploy.json) f deploy set-host user@host:port f deploy set-host deploy@myserver.com:22 f deploy set-host root@192.168.1.100 # Interactive config (prefills from ~/.config/infra/config.json if present) f deploy config # Verify connection f deploy shell ``` ### Deploy ```bash # Deploy to host f deploy host # Force re-run setup script f deploy host --setup # Build remotely instead of syncing local artifacts f deploy host --remote-build ``` ### What Happens 1. **Sync files** - rsync uploads project (excludes `target/`, `.git/`, `node_modules/`, `.env`, `*.log`) 2. **Copy env file** - If `env_file` is specified, copies it to `{dest}/.env` (or, if `env_source = "flow"`, fetches from Flow env store and writes `{dest}/.env`) 3. **Run setup** - Executes setup script on first deploy or with `--setup` 4. **Create systemd service** - Generates and enables `/etc/systemd/system/{service}.service` 5. **Configure nginx** - If `domain` is set, creates reverse proxy config 6. **Setup SSL** - If `ssl = true`, runs certbot for Let's Encrypt certificate 7. **Start service** - Runs `systemctl restart {service}` ### Manage Service ```bash # View logs f deploy logs # Since last deploy (host only) f deploy logs -f # Follow in real-time (since last deploy) f deploy logs --all # Full history (ignore deploy marker) f deploy logs --all -n 500 # Show last 500 lines without deploy filter # Restart/stop f deploy restart f deploy stop # Check status f deploy status # SSH into server f deploy shell # Health check f deploy health f deploy health --url https://myapp.example.com/health f deploy health --status 204 # Expect specific status code ``` Note: For host deploys, Flow records the last successful deploy time in `.flow/deploy-log.json` and uses it to scope `f deploy logs` output. Use `--all` to ignore it. --- ## Cloudflare Workers Deploy to Cloudflare's edge network. ### Configuration Add to `flow.toml`: ```toml [cloudflare] path = "worker" # Path to worker directory (optional, defaults to project root) environment = "production" # Wrangler environment name (optional) env_file = ".env.cloudflare" # Path to .env file for secrets (optional) env_source = "cloud" # Use cloud or local env store for secrets (optional) env_keys = ["API_KEY", "SECRET"] # Specific keys to fetch from env store (optional) env_vars = ["PUBLIC_URL"] # Keys to set as non-secret vars (optional) deploy = "wrangler deploy" # Custom deploy command (optional) dev = "wrangler dev" # Custom dev command (optional) url = "https://my-worker.workers.dev" # URL for health checks (optional) ``` ### Prerequisites - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) installed - `wrangler.toml` in your worker directory - Authenticated with `wrangler login` ### Deploy ```bash # Deploy f deploy cloudflare # Deploy with secrets from env_file f deploy cloudflare --secrets # Run in dev mode f deploy cloudflare --dev ``` ### Production domain (for `f prod`) Use `[prod]` to set a production domain or route for Workers. `f prod` will add the route to your `wrangler.json/jsonc` before deploying. ```toml [prod] domain = "anysynth.nikiv.com" # Will be mapped to route "anysynth.nikiv.com/*" # route = "anysynth.nikiv.com/*" # Use this for explicit patterns ``` ### Interactive Setup For first-time setup, use the interactive wizard: ```bash f deploy setup ``` This walks you through: 1. Selecting worker directory (auto-discovers `wrangler.toml`) 2. Choosing .env file for secrets 3. Selecting Cloudflare environment (production, staging, etc.) 4. Picking which secrets to push 5. Updating `flow.toml` with your choices If your `flow.toml` lists service keys (for example Stripe keys), the setup flow will offer to run the matching service onboarding before applying envs. ### Secrets from cloud If using cloud for secret management: ```toml [cloudflare] env_source = "cloud" env_keys = ["ANTHROPIC_API_KEY", "DATABASE_URL"] # Fetched as secrets env_vars = ["PUBLIC_API_URL"] # Fetched as non-secret vars environment = "production" ``` Then deploy: ```bash f deploy cloudflare --secrets ``` If you want to use local Flow envs instead: ```toml [cloudflare] env_source = "local" env_keys = ["ANTHROPIC_API_KEY", "DATABASE_URL"] env_vars = ["PUBLIC_API_URL"] ``` If you need to fill missing values first: ```bash f env guide ``` --- ## Railway Deploy to Railway's platform. ### Configuration Add to `flow.toml`: ```toml [railway] project = "my-project" # Railway project ID service = "api" # Service name (optional) environment = "production" # Environment name (optional) start = "npm start" # Start command (optional) env_file = ".env.railway" # Path to .env file (optional) ``` ### Prerequisites - [Railway CLI](https://docs.railway.app/develop/cli) installed (`npm install -g @railway/cli`) - Authenticated with `railway login` ### Deploy ```bash f deploy railway ``` What happens: 1. Links to Railway project if specified 2. Sets environment variables from `env_file` 3. Deploys with `railway up --detach` --- ## Health Checks Check if your deployment is responding: ```bash # Use domain from [host] or url from [cloudflare] config f deploy health # Custom URL f deploy health --url https://api.example.com/health # Expect specific status code f deploy health --status 204 ``` Returns: - `Healthy (HTTP 200 in 0.15s)` on success - `Unhealthy: expected HTTP 200, got 500` on wrong status - `Unreachable: Connection refused` on network error --- ## Global Host Configuration Host connection is stored globally at `~/.config/flow/deploy.json`: ```json { "host": { "user": "deploy", "host": "myserver.com", "port": 22 } } ``` View/set: ```bash f deploy show-host f deploy set-host deploy@newserver.com:2222 ``` --- ## Examples ### Full Host Setup ```toml # flow.toml [host] dest = "/opt/api" run = "/opt/api/server" port = 8080 service = "myapi" setup = "cargo build --release && cp target/release/server /opt/api/" env_file = ".env.production" domain = "api.mycompany.com" ssl = true ``` ```bash # First time setup f deploy set-host root@myserver.com # Deploy f deploy host # Check it's working f deploy health f deploy logs -f ``` ### Full Cloudflare Setup ```toml # flow.toml [cloudflare] path = "packages/worker" environment = "production" env_source = "cloud" env_keys = ["OPENAI_API_KEY", "WEBHOOK_SECRET"] url = "https://my-worker.mycompany.workers.dev" ``` ```bash # Store project secrets f env project set -e production OPENAI_API_KEY=sk-... f env project set -e production WEBHOOK_SECRET=whsec_... # Deploy with secrets f deploy cloudflare --secrets # Verify f deploy health ``` ### CI/CD Integration ```yaml # GitHub Actions deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy run: | f deploy set-host ${{ secrets.DEPLOY_HOST }} f deploy host f deploy health ``` --- ## Troubleshooting ### "No host configured" Run `f deploy set-host user@host:port` first. ### "No wrangler config found" Ensure `wrangler.toml` exists in your worker directory, or run `wrangler init`. ### SSH connection fails Test with `f deploy shell` to debug. Check: - SSH key is in `~/.ssh/` and added to server - Port is correct (default: 22) - Server is reachable ### Secrets not updating For Cloudflare, use `--secrets` flag: `f deploy cloudflare --secrets` ### Health check fails Check URL is correct and service is running: ```bash f deploy logs -f # Check for errors curl -v https://your-domain.com # Test manually ``` ================================================ FILE: docs/commands/docs.md ================================================ # f docs Manage documentation for a project. There are two doc systems: - `.ai/docs` for AI-maintained internal docs. - `docs/` for human-facing docs rendered by the docs hub. ## Quick Start ```bash # Create docs/ with starter files f docs new # Open docs for the current project (auto-starts the hub) f docs # Deploy the current project's docs to Cloudflare Pages f docs deploy ``` ## Commands ### f docs new Creates a `docs/` folder in the current project using the template in `~/new/docs`. If `docs/` already exists, it merges in any missing template files and leaves existing content untouched. Options: - `--path <PATH>`: Target directory (defaults to current folder). - `--force`: Overwrite if `docs/` already exists. ### f docs hub Runs a single dev server that aggregates docs from `~/code` and `~/org`. Use `FLOW_DOCS_FOCUS=1` to only index the current project for faster startup. Options: - `--host <HOST>` (default: `127.0.0.1`) - `--port <PORT>` (default: `4410`) - `--hub-root <PATH>` (default: `~/.config/flow/docs-hub`) - `--template-root <PATH>` (default: `~/new/docs`) - `--code-root <PATH>` (default: `~/code`) - `--org-root <PATH>` (default: `~/org`) - `--no-ai`: Skip `.ai/docs`. - `--no-open`: Do not open the browser. - `--sync-only`: Sync docs content and exit. ### f docs deploy Deploys the current project's docs to Cloudflare Pages (uses the docs hub template). Options: - `--project <NAME>`: Pages project name (defaults to the flow.toml name). - `--domain <DOMAIN>`: Attach a custom domain (optional). - `--yes`: Skip confirmation prompts. ### f docs sync Syncs `.ai/docs` metadata based on recent commits. Intended for AI doc upkeep. ### f docs list Lists `.ai/docs` files for the current project. ### f docs status Shows recent commits and `.ai/docs` file modification times. ### f docs edit Opens a `.ai/docs` file in `$EDITOR`. Example: ```bash f docs edit architecture ``` ================================================ FILE: docs/commands/domains.md ================================================ # `f domains` Manage shared local `*.localhost` routing with a single proxy on port `80`. ## Why Without this, each repo can start its own proxy and race for port `80`. `f domains` centralizes ownership with one engine at a time. State lives in: - `~/.config/flow/local-domains/routes.json` - runtime artifacts under `~/.config/flow/local-domains/` ## Engines - `docker` (default): shared nginx container (`flow-local-domains-proxy`) - `native` (experimental): local C++ daemon (`domainsd-cpp`) Select engine per command: ```bash f domains --engine docker up f domains --engine native up ``` Or via env: ```bash export FLOW_DOMAINS_ENGINE=native ``` ## Commands ```bash f domains list f domains add linsa.localhost 127.0.0.1:3481 f domains rm linsa.localhost f domains up f domains down f domains doctor f domains --engine native up f domains --engine native down f domains --engine native doctor ``` ## Behavior - `f domains up` - starts shared proxy on `:80` - fails fast if another process/container owns port `80` - `f domains add` - validates `host` ends with `.localhost` - validates target format `host:port` - refuses overwrite unless `--replace` - reloads proxy if already running - `f domains doctor` - shows route count - shows current owner of port `80` - highlights conflict ownership ## Native notes (experimental) - Requires `clang++` to build `tools/domainsd-cpp/domainsd.cpp`. - Current scope is HTTP/1.1 host routing with WebSocket upgrade passthrough and upstream keepalive pooling. - Native daemon has built-in overload shedding (`503`) and upstream timeout protection (`504` on connect timeout). - HTTP/2/TLS are not implemented yet. - See `docs/local-domains-domainsd-cpp-spec.md`. - myflow-specific setup: `docs/myflow-localhost-runbook.md`. - Lifecycle integration: configure `[lifecycle.domains]` and use `f up` / `f down`. ### macOS no-docker bind to :80 If native startup fails with `Permission denied` on `127.0.0.1:80`, install launchd socket mode once: ```bash cd ~/code/flow sudo ./tools/domainsd-cpp/install-macos-launchd.sh ``` Then run: ```bash f domains --engine native up ``` This keeps routing fully native and avoids Docker overhead while still using port `80`. ### Native tuning envs You can tune the native daemon at startup via environment variables: ```bash FLOW_DOMAINS_NATIVE_MAX_ACTIVE_CLIENTS=128 FLOW_DOMAINS_NATIVE_UPSTREAM_CONNECT_TIMEOUT_MS=10000 FLOW_DOMAINS_NATIVE_UPSTREAM_IO_TIMEOUT_MS=15000 FLOW_DOMAINS_NATIVE_CLIENT_IO_TIMEOUT_MS=30000 FLOW_DOMAINS_NATIVE_POOL_MAX_IDLE_PER_KEY=8 FLOW_DOMAINS_NATIVE_POOL_MAX_IDLE_TOTAL=256 FLOW_DOMAINS_NATIVE_POOL_IDLE_TIMEOUT_MS=15000 FLOW_DOMAINS_NATIVE_POOL_MAX_AGE_MS=120000 ``` ## Recommended Repo Pattern Instead of per-repo docker proxy tasks: ```toml [[tasks]] name = "domains-up" command = "f domains add myapp.localhost 127.0.0.1:3000 && f domains up" ``` This keeps one proxy process for all repos and avoids accidental domain hijacking. ================================================ FILE: docs/commands/down.md ================================================ # f down Bring a project down using lifecycle conventions. ## Quick Start ```bash # Run lifecycle down task and optional domains teardown f down ``` ## Behavior - Loads nearest `flow.toml` (or `--config` path). - Runs lifecycle task: - `[lifecycle].down_task` when configured - otherwise fallback: `down` - If no down task is found and `down_task` is not explicitly set, Flow falls back to killing all running Flow-managed processes for the current project (`f kill --all` behavior). - If `[lifecycle.domains]` is configured, optional teardown is applied: - `remove_on_down = true` -> removes configured host route - `stop_proxy_on_down = true` -> stops shared local domains proxy - On macOS launchd-managed native domains, stopping native proxy is handled by: - `sudo ./tools/domainsd-cpp/uninstall-macos-launchd.sh` If neither a down task nor lifecycle domain teardown is configured, command fails with guidance. ## Options | Option | Description | |--------|-------------| | `--config <PATH>` | Path to `flow.toml` (default: `./flow.toml`, searches upward when default is missing) | | `ARGS...` | Extra args passed to the selected lifecycle task | ================================================ FILE: docs/commands/env.md ================================================ # f env Sync project environment and manage environment variables. ## Overview Manage environment variables via cloud or local storage. Supports: - Project-level environment variables - Personal/global variables - Multiple environments (dev, staging, production) - Direct injection into commands - Touch ID gating for cloud reads and keychain-backed personal local reads on macOS - Client-side sealed project env sharing in the cloud ## Storage Backends | Backend | Location | Config | |---------|----------|--------| | `cloud` | Cloud (myflow.sh) | Default, requires login | | `local` | `~/.config/flow/env-local/` | No account needed | Cloud behavior: - Personal cloud envs use Flow's existing server-managed secret storage. - Project cloud envs are sealed client-side before upload and decrypted locally on read. - If a host deploy is configured with `env_source = "cloud"` plus a `service_token`, Flow keeps a compatibility plaintext mirror for those project keys until the host fetch path is upgraded. Force local backend: ```bash # Via environment variable export FLOW_ENV_BACKEND=local # Or in ~/.config/flow/config.ts export default { flow: { env: { backend: "local" } } } ``` If the current project has an unambiguous deploy env source such as: ```toml [host] env_source = "local" ``` then `f env` will also use the local backend automatically in that project. ### Local Storage Structure ``` ~/.config/flow/env-local/ ├── <project-name>/ │ ├── production.env │ ├── staging.env │ └── dev.env └── personal/ └── production.env ``` Storage behavior: - Project-local envs are private `.env` files under `~/.config/flow/env-local/`. - On macOS, personal-local env values are stored in Keychain by default; `personal/production.env` keeps Flow-managed references, not raw secret values. - If `FLOW_ENV_LOCAL_PLAINTEXT=1` is set, Flow falls back to plaintext personal local storage. - Local env paths are written with owner-only permissions. ## Quick Start ```bash # Store a personal secret f env set API_KEY=sk-xxx # List variables (default action when logged in) f env # Run command with env vars injected f env run -- npm start # Get a value f env get API_KEY -f value ``` ## Linsa/TestFlight Example For project-scoped keys used by local + ship flows (for example, assistant keys): ```bash # Store in project space (recommended for app/server env) f env project set -e dev OPENROUTER_API_KEY=sk-... f env project set -e production OPENROUTER_API_KEY=sk-... f env project set -e dev OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 f env project set -e production OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 ``` Notes: - Do not commit secrets to docs or repository files. - Use `f env get -e production -f value OPENROUTER_API_KEY` at runtime when a ship script needs to inject a missing key. ## Subcommands | Command | Description | |---------|-------------| | `login` | Authenticate with cloud | | `set` | Set a single env var | | `get` | Get specific env var(s) | | `list` | List env vars for this project | | `delete` | Delete env var(s) | | `pull` | Fetch env vars and write to .env | | `push` | Push local .env to cloud | | `apply` | Apply env vars to Cloudflare worker | | `setup` | Interactive wizard to push env vars | | `run` | Run command with env vars injected | | `status` | Show current auth status | | `keys` | Show configured env keys from flow.toml | | `sync` | Sync project settings and hub workflow | | `bootstrap` | Bootstrap Cloudflare secrets from flow.toml | | `unlock` | Unlock env reads (Touch ID on macOS) | --- ## Set Store a personal environment variable: ```bash # Basic set f env set API_KEY=sk-xxx # Personal envs always use the production personal store f env set GITHUB_TOKEN=ghp_xxx ``` ### Options | Option | Short | Description | |--------|-------|-------------| | `--personal` | | Compatibility flag; `set` already targets personal envs | ## Project Set Store a project-scoped environment variable: ```bash f env project set -e dev DATABASE_URL=postgres://localhost/app f env project set -e production PUBLIC_API_BASE_URL=https://api.example.com ``` Notes: - Project cloud writes are sealed by default. - On a new device, the first project read/write auto-registers that device as a sealer. - If a key exists in cloud but was never shared to this device, Flow will ask you to re-save it from a device that already has access. --- ## Get Retrieve environment variables: ```bash # Get as KEY=VALUE f env get API_KEY # API_KEY=sk-xxx # Get just the value f env get API_KEY -f value # sk-xxx # Get as JSON f env get API_KEY -f json # {"API_KEY": "sk-xxx"} # Get multiple f env get API_KEY DATABASE_URL # From personal store f env get --personal GITHUB_TOKEN -f value ``` ### Options | Option | Short | Description | |--------|-------|-------------| | `--environment <ENV>` | `-e` | Environment (default: production) | | `--format <FORMAT>` | `-f` | Output: `env`, `json`, or `value` (default: env) | | `--personal` | | Fetch from personal store | --- ## List List all environment variables: ```bash # List production vars f env list # List staging vars f env list -e staging ``` Output: ``` Environment: production API_KEY OpenAI API key DATABASE_URL PostgreSQL connection string REDIS_URL - ``` --- ## Run Run a command with environment variables injected: ```bash # Inject all project env vars f env run -- npm start # Inject specific keys f env run -k API_KEY -k DATABASE_URL -- node server.js # From personal store f env run --personal -k GITHUB_TOKEN -- gh repo list # Multiple keys from personal f env run --personal -k TELEGRAM_BOT_TOKEN -k TELEGRAM_API_ID -- ./start.sh ``` ### Options | Option | Short | Description | |--------|-------|-------------| | `--environment <ENV>` | `-e` | Environment (default: production) | | `--keys <KEYS>` | `-k` | Specific keys to inject (repeatable) | | `--personal` | | Fetch from personal store | ### Examples ```bash # Start app with production secrets f env run -- npm run start # Run with staging environment f env run -e staging -- npm run dev # Telegram bot with personal tokens f env run --personal -k TELEGRAM_BOT_TOKEN -- pnpm tsx src/bot.ts ``` --- ## Pull Fetch all env vars and write to `.env` file: ```bash # Write to .env f env pull # Pull staging vars f env pull -e staging ``` Creates or overwrites `.env` in current directory with all project variables. --- ## Push Upload local `.env` file to cloud: ```bash f env push ``` Reads `.env` from current directory and stores all variables. --- ## Setup Interactive wizard for pushing env vars: ```bash f env setup ``` If `[cloudflare] env_source = "cloud"` is set in `flow.toml`, this runs a guided prompt based on `env_keys`/`env_vars`. Otherwise it guides you through: 1. Reading your `.env` file 2. Selecting which keys to push 3. Confirming before upload --- ## Delete Remove environment variables: ```bash # Delete single key f env delete API_KEY # Delete multiple f env delete API_KEY DATABASE_URL ``` --- ## Login Authenticate with cloud: ```bash f env login ``` Prompts for API base URL and token. On macOS, the token is stored in Keychain and Flow uses Touch ID to unlock env reads. --- ## Status Check authentication status: ```bash f env status ``` Output: ``` cloud Status Token: stored in Keychain API: https://myflow.sh Project: myproject ``` --- ## Apply Apply env vars to Cloudflare worker (uses `[cloudflare]` config in flow.toml): ```bash f env apply ``` Requires `env_source = "cloud"` in your `[cloudflare]` config. ## Keys Show env keys configured in `flow.toml` without printing values: ```bash f env keys ``` ## Unlock On macOS, unlock env reads for the day (Touch ID): ```bash f env unlock ``` --- ## Environments Flow supports three environments: | Environment | Description | |-------------|-------------| | `production` | Production secrets (default) | | `staging` | Staging/preview secrets | | `dev` | Development secrets | Use `-e` flag with project-scoped commands: ```bash f env project set DATABASE_URL=postgres://staging... -e staging f env list -e dev f env run -e staging -- npm run preview ``` --- ## Personal vs Project Variables | Type | Flag | Scope | Use Case | |------|------|-------|----------| | Project | (default for `get`, `list`, `run`, `project ...`) | Current project | API keys, database URLs | | Personal | `--personal` | Global/user | GitHub token, Telegram bot token | Personal variables are tied to your user account, not a specific project. `f env set` writes personal vars. Use `f env project set` for project vars. ```bash # Use a personal token in any project f env run --personal -k ANTHROPIC_API_KEY -- ./my-script ``` --- ## Env Space Overrides You can store project envs under a named cloud space by configuring `env_space` and `env_space_kind` in `flow.toml`. ```toml # flow.toml env_space = "nikiv" env_space_kind = "personal" ``` - `env_space_kind = "project"` (default) uses the project name. - `env_space_kind = "personal"` routes project envs to your personal space. This affects `f env pull`, `f env push`, `f env list`, `f env apply`, and service token creation. --- ## Examples ### Typical Workflow ```bash # 1. Authenticate f env login # 2. Set up secrets f env project set DATABASE_URL=postgres://... -e production f env project set API_KEY=sk-xxx -e production # 3. Verify f env list # 4. Run app f env run -- npm start ``` ### Staging Deploy ```bash # Set staging secrets f env project set DATABASE_URL=postgres://staging... -e staging f env project set API_KEY=sk-test-xxx -e staging # Test with staging env f env run -e staging -- npm run preview ``` ### Personal Tokens for CLI Tools ```bash # Store once f env set --personal GITHUB_TOKEN=ghp_xxx f env set --personal TELEGRAM_BOT_TOKEN=xxx # Use anywhere f env run --personal -k GITHUB_TOKEN -- gh repo list ``` ### In flow.toml Tasks ```toml [tasks.start] command = "f env run -- npm start" [tasks.bot] command = "f env run --personal -k TELEGRAM_BOT_TOKEN -- node bot.js" ``` --- ## Troubleshooting ### "Not authenticated" Run `f env login` to authenticate with cloud. ### "No env vars found" Check environment name - default is `production`: ```bash f env list -e staging # Check staging instead ``` ### Variables not injecting Ensure command comes after `--`: ```bash f env run -k API_KEY -- npm start # Correct f env run -k API_KEY npm start # May not work ``` ## See Also - [deploy](deploy.md) - Using env vars in deployments - [docs/how-to-use-env.md](../how-to-use-env.md) - Extended usage guide ================================================ FILE: docs/commands/fast.md ================================================ # f fast Run AI tasks through the low-latency fast client path. This command is optimized for hot-loop invocation and prefers `fai`/`ai-taskd-client` over full `f` task startup. ## Usage ```bash f fast ai:flow/noop f fast ai:flow/bench-cli -- --iterations 30 f fast --root ~/code/flow ai:flow/dev-check f fast --no-cache ai:flow/dev-check ``` ## Behavior 1. Tries fast client dispatch (`fai`, local `target/.../ai-taskd-client`, or `ai-taskd-client` on PATH). 2. If daemon is not running, starts `ai-taskd` and retries. 3. Falls back to direct daemon dispatch if no fast client binary is found. ## Options - `--root <PATH>`: root directory for `.ai/tasks` discovery (default `.`) - `--no-cache`: disable cached binary execution and use direct Moon run - `TASK`: required AI selector like `ai:flow/dev-check` - trailing args after `--` are passed to the task ## Notes - `f fast` is intentionally for `ai:*` selectors only. - Install `fai` for best latency: ```bash f install-ai-fast-client f tasks daemon start f fast ai:flow/noop ``` For pooled burst execution and timings, use `fai` directly: ```bash fai --timings ai:flow/noop printf 'ai:flow/noop\nai:flow/noop\n' | fai --batch-stdin --timings ``` ================================================ FILE: docs/commands/global.md ================================================ # f global Run tasks from your global flow config (`~/.config/flow/flow.toml`). ## Quick Start ```bash # List global tasks f global --list f global list # Run a global task from anywhere f global repos-clone-safari f global run repos-clone-safari # Match a query against global tasks f global match "clone safari repo" ``` ## Options | Option | Short | Description | |--------|-------|-------------| | `<TASK>` | | Global task name (omit to list) | | `list` | | List global tasks | | `run <TASK>` | | Run a global task | | `match <QUERY>` | | Match a query against global tasks | | `--list` | `-l` | List global tasks | | `-- <ARGS...>` | | Pass extra args to the task | ## Examples ```bash f global repos-clone-safari https://github.com/0xPlaygrounds/rig ``` ================================================ FILE: docs/commands/install.md ================================================ # f install Install a CLI/tool binary into your PATH. Default backend behavior (`--backend auto`): 1. Flow registry (`myflow.sh` or `--registry`) 2. GitHub releases via `parm` 3. flox package install ## Usage ```bash f install <name> ``` ## Options - `--registry <URL>`: registry base URL (defaults to `FLOW_REGISTRY_URL`). - `--backend <auto|registry|parm|flox>`: choose install backend explicitly. - `--version <VERSION>`: install a specific version (defaults to latest). - `--bin <NAME>`: binary name to install (defaults to manifest default or package name). - `--bin-dir <PATH>`: install directory (defaults to `~/bin`). - `--force`: overwrite an existing binary. - `--no-verify`: skip checksum verification. ## Auto backend notes - `f install rise` can resolve through `parm` using built-in owner/repo mapping. - Built-in aliases: - `f install seqd` resolves registry package `seq` with binary `seqd`. - `f install lin` resolves registry package `flow` with binary `lin`. - If a package name is ambiguous for `parm`, set `FLOW_INSTALL_OWNER` (env or Flow personal env store) or pass `owner/repo` directly. ## Bootstrap from installer The hosted installer can bootstrap core tools after installing flow: - `FLOW_BOOTSTRAP_TOOLS="rise seq seqd"` (default) installs those with `f install ... --backend auto`. - `FLOW_BOOTSTRAP_TOOLS=0` disables this. - `FLOW_BOOTSTRAP_INSTALL_PARM=1` (default) attempts to install `parm` for robust GitHub fallback. ## Registry layout The registry must expose: - `GET /packages/<name>/latest.json` - `GET /packages/<name>/<version>/manifest.json` - `GET /packages/<name>/<version>/<target>/<bin>` ## Example ```bash FLOW_REGISTRY_URL=https://myflow.sh f install flow ``` ```bash f install rise ``` ================================================ FILE: docs/commands/invariants.md ================================================ # f invariants Validate project invariants declared in `flow.toml`. This command checks the `[invariants]` section and reports violations in changed code. ## Usage ```bash # Check all local changes vs HEAD f invariants # Check only staged changes f invariants --staged ``` ## What It Checks 1. `forbidden`: disallowed patterns in added diff lines. 2. `deps.approved`: unapproved dependencies in `package.json` sections (`dependencies`, `devDependencies`, `peerDependencies`). 3. `files.max_lines`: changed files exceeding the configured line limit. ## Modes Set in `flow.toml`: - `mode = "off"`: disable checks. - `mode = "warn"`: print findings, exit success. - `mode = "block"`: fail command when blocking findings exist. ## Example `flow.toml` ```toml [invariants] mode = "block" architecture_style = "layered monorepo, event-driven core" non_negotiable = [ "no inline imports", "no any types unless justified", ] forbidden = [ "git add -A", "git reset --hard", ] [invariants.terminology] "pi-ai" = "LLM abstraction layer" "pi-agent" = "stateful agent runtime" [invariants.deps] policy = "approval_required" approved = ["@sinclair/typebox", "@reatom/core"] [invariants.files] max_lines = 300 ``` ## Commit Integration `f commit` runs the invariant gate during commit-with-check flow. - In `mode = "block"`, commits are blocked on invariant warnings/critical findings. - Invariant context and findings are injected into AI review prompts. ================================================ FILE: docs/commands/jj.md ================================================ # jj Flow wraps common Jujutsu (jj) workflows so you can stay in jj while remaining fully Git-compatible. ## Quick start ```bash # Inspect workspace + home-branch state f status # Initialize jj (colocated with git when .git exists) f jj init # Create a feature bookmark and track origin f jj bookmark create feature-x --track # Fetch + rebase onto main + push bookmark f jj sync --bookmark feature-x ``` ## Commands - `f status` — Show workflow-aware JJ status (workspace, home branch, leaf branches, working-copy summary, and next-step hints) - `f jj status` — Show raw `jj st` - `f jj fetch` — `jj git fetch` - `f jj rebase --dest <branch>` — Rebase onto `jj.default_branch` (or main/master) - `f jj push --bookmark <name>` — Push a single bookmark - `f jj push --all` — Push all bookmarks - `f jj sync --bookmark <name>` — Fetch, rebase, then push bookmark - `f jj workspace add <name> [--path <dir>] [--rev <rev>]` — Create a workspace (optionally anchored to a revision) - `f jj workspace lane <name> [--path <dir>] [--base <rev>] [--remote <name>] [--no-fetch]` — Create an isolated parallel lane from trunk defaults - `f jj workspace review <branch> [--path <dir>] [--base <rev>] [--remote <name>] [--no-fetch]` — Create or reuse a stable JJ review workspace for a branch without touching the current checkout - `f jj workspace list` — List workspaces - `f jj bookmark create <name> [--rev <rev>] [--track]` — Create bookmark - `f jj bookmark track <name> [--remote <remote>]` — Track remote bookmark ## Status-first workflow Use `f status` before branch, workspace, commit, or publish operations. It is the fast way to answer: - which workspace am I in? - am I on the long-lived home branch or a branch-specific leaf? - what review or codex branches currently sit on top of the home branch? - is the working copy clean enough to mutate safely? Example: ```bash cd ~/code/org/project f status ``` Use `f jj status` only when you want the raw Jujutsu working-copy view. ## Parallel lanes (no interleaving) Use lanes when you want multiple active tasks in one repo without stash/pop churn: ```bash # In your current repo, create isolated lanes anchored from trunk f jj workspace lane fix-otp f jj workspace lane testflight # Work each lane independently cd ~/.jj/workspaces/<repo>/fix-otp jj st ``` `f jj workspace lane` does: 1. `jj git fetch` (unless `--no-fetch`) 2. chooses base as `<default_branch>@<remote>` when tracked (fallback: `<default_branch>`) 3. creates a dedicated workspace with its own `@` working-copy commit ## Review workspaces Use a review workspace when you want an isolated JJ working copy for a review branch without touching the current repo checkout: ```bash f jj workspace review review/alice-feature cd ~/.jj/workspaces/<repo>/review-alice-feature jj st ``` `f jj workspace review` does: 1. `jj git fetch` (unless `--no-fetch`) 2. reuses the existing review workspace when present 3. otherwise anchors the workspace at the local branch commit, then the remote branch commit, then trunk 4. prints the stable workspace path plus the JJ bookmark command to publish later Important: - The review workspace is JJ-native. Use `jj` or `f jj` inside it. - In colocated repos, plain `git` still points at the main checkout, so this command intentionally does not run `flow switch` for you. ## Config Add to `flow.toml`: ```toml [git] remote = "myflow-i" # optional preferred writable remote [jj] default_branch = "main" home_branch = "alice" # optional long-lived personal integration branch remote = "origin" # optional legacy fallback if [git].remote is unset auto_track = true ``` This keeps jj aligned with Git remotes while you work locally in jj. ## Home-branch model If you keep a long-lived personal branch on top of trunk, set `jj.home_branch` and treat it as your integration branch. Then use short-lived `review/*` or `codex/*` branches on top of that home branch for task-specific work. `f status` is optimized to make that shape visible. Recommended flow: ```bash # Default checkout stays on your home branch cd ~/code/org/project f status # Branch-specific work happens in isolated workspaces f jj workspace review review/alice-feature cd ~/.jj/workspaces/project/review-alice-feature f status ``` See also: - [`jj-review-workspaces.md`](../jj-review-workspaces.md) - [`jj-home-branch-workflow.md`](../jj-home-branch-workflow.md) ================================================ FILE: docs/commands/migrate.md ================================================ # f migrate Move or copy a project folder to a new location, preserving symlinks and AI sessions. ## Overview Relocates a project directory and automatically migrates Claude and Codex AI sessions so conversation history follows the project. Also relinks any `~/bin` symlinks that pointed into the old path (move only). ## Quick Start ```bash # Move current directory into ~/code/lang/cpp/stream cd ~/code/lang/cpp/stream f migrate code stream # Move current directory to an arbitrary path f migrate ~/code/stream # Move a specific source to a target f migrate ~/code/lang/cpp/stream ~/code/stream # Preview what would happen (no changes) f migrate --dry-run code stream # Copy instead of move (keeps original) f migrate --copy code stream ``` ## Your case To migrate `~/code/lang/cpp/stream` to `~/code/stream`: ```bash cd ~/code/lang/cpp/stream f migrate code stream ``` Or without `cd`: ```bash f migrate ~/code/lang/cpp/stream ~/code/stream ``` Preview first with `--dry-run`: ```bash f migrate --dry-run ~/code/lang/cpp/stream ~/code/stream ``` ## Usage Forms ### `f migrate code <relative>` Moves the current directory into `~/code/<relative>`. This is the most common form. ```bash cd ~/old/location/myproject f migrate code myproject # -> ~/code/myproject f migrate code lang/rust/mylib # -> ~/code/lang/rust/mylib ``` ### `f migrate <target>` Moves the current directory to `<target>` (any absolute or relative path). ```bash cd ~/old/location/myproject f migrate ~/code/myproject ``` ### `f migrate <source> <target>` Moves `<source>` to `<target>` without needing to `cd` first. ```bash f migrate ~/code/lang/cpp/stream ~/code/stream ``` ## Options | Option | Short | Description | |--------|-------|-------------| | `--copy` | `-c` | Copy instead of move (keeps the original intact) | | `--dry-run` | | Show what would change without writing anything | | `--skip-claude` | | Skip migrating Claude Code sessions | | `--skip-codex` | | Skip migrating Codex sessions | ## What Happens ### Step 1: Move or copy the folder The project directory is moved (or copied with `--copy`) to the target path. Parent directories are created automatically. Cross-device moves are handled transparently (copy + delete). ### Step 2: Relink ~/bin symlinks (move only) Any symlinks in `~/bin` that pointed into the old path are updated to point to the new location. Skipped when using `--copy`. ### Step 3: Migrate AI sessions Claude and Codex store project sessions keyed by filesystem path. Migrate updates these so conversation history is preserved at the new location. - **Claude sessions**: Project directories under `~/.claude/projects/` are renamed from the old path-based key to the new one. - **Codex sessions**: Legacy project directories are renamed, and `.jsonl` session files referencing the old path are updated in-place. A summary is printed showing how many session dirs/files were migrated. ## Examples ```bash # Move project into ~/code, nested path cd ~/downloads/cool-project f migrate code tools/cool-project # Copy a project (keeps original) f migrate --copy ~/code/app ~/backup/app # Dry run to preview f migrate --dry-run code stream # Move without migrating AI sessions f migrate --skip-claude --skip-codex ~/old/path ~/new/path # Move and only migrate Claude (skip Codex) f migrate --skip-codex code myproject ``` ## Troubleshooting ### "Destination already exists" The target path must not exist. Remove or rename the existing directory first, or choose a different target. ### "Source and destination are the same path" Both paths resolve to the same location. Double-check your arguments. ### Session migration warnings After a move, you may see warnings like: ``` WARN Claude session dir still present: ... WARN Codex sessions still reference the old path: /path/to/file.jsonl ``` These mean some sessions couldn't be fully migrated. You can manually inspect or delete the referenced files. ## See Also - [repos](repos.md) - Clone repositories into structured layout ================================================ FILE: docs/commands/new.md ================================================ # f new Create a new project from a local starter template under `~/new`. ## Overview `f new` copies a directory from `~/new/<template>` into a destination path. This is the native Flow way to work with local starters you keep in `~/new`. ## Usage ```bash f new [template] [path] ``` - `template`: folder name inside `~/new` (for example `app`, `docs`, `web`) - `path`: destination path (optional) If `template` is omitted, Flow opens an `fzf` picker from templates in `~/new`. ## Path Resolution Rules Flow resolves the destination path like this: ```bash f new app # -> ./app f new app zerg # -> ~/code/zerg f new app ./xn # -> ./xn f new app ~/xn # -> ~/xn ``` Notes: - Plain names (no `./`, `../`, `/`, `~`) are treated as `~/code/<name>`. - Use `~/...` or absolute paths for custom locations outside `~/code`. ## Dry Run Preview copy behavior without writing files: ```bash f new app ~/xn --dry-run ``` ## Starter Workflow 1. Create or update starter in `~/new/<template>`. 2. Generate a new project with `f new <template> <target>`. 3. Enter the new project and run its setup/dev tasks with Flow. Example: ```bash f new app ~/xn cd ~/xn f tasks ``` ## Common Errors - `Template not found`: `~/new/<template>` does not exist. - `Destination already exists`: remove/rename target path or choose a new destination. ================================================ FILE: docs/commands/pr.md ================================================ # f pr Create or open GitHub pull requests, edit PR text locally, and pull review feedback. ## Quick Start ```bash # Create/update PR from current changes (default base: main) f pr "assistant improvements" # Open current branch PR in browser f pr open # Edit PR title/body in local markdown and sync on save f pr open edit # Pull actionable review feedback for current PR f pr feedback # Pull feedback for specific PR and store as local todos f pr feedback 8 --todo f pr feedback https://github.com/owner/repo/pull/8 --todo # Full inline diff/review context is now the default f pr feedback 8 # Use terse terminal output if needed f pr feedback 8 --compact ``` ## Feedback Workflow `f pr feedback` fetches: - PR reviews (`/pulls/<n>/reviews`) - Inline review comments (`/pulls/<n>/comments`) - Top-level PR comments (`/issues/<n>/comments`) It then: 1. Prints an actionable list in terminal with inline review state and diff hunk context by default. 2. Writes a markdown snapshot to `.ai/reviews/pr-feedback-<pr>.md`. 3. Writes a machine-readable JSON snapshot to `.ai/reviews/pr-feedback-<pr>.json`. 4. Writes a human review plan to `~/plan/review/<repo>-pr-<pr>-feedback.md`. 5. Writes a PR-local execution artifact to `~/plan/review/<repo>-pr-<pr>-review-rules.md`. 6. Writes a Kit system prompt to `~/plan/review/<repo>-pr-<pr>-kit-system.md`. 7. Optionally (`--todo`) records feedback into `.ai/todos/todos.json` with dedupe via external refs. The review plan includes ready-to-run `kit` commands for: - deterministic repo review via `kit review` - preventative lint/review rule synthesis from the fetched GitHub feedback set The generated `*-review-rules.md` artifact contains the per-item resolution loop, prompt template, required response sections, and Kit-upgrade decision order so the workflow can be reopened without extra instructions. ## Notes - If no selector is passed, Flow resolves the PR from the current branch. - `f pr feedback --todo` is safe to re-run; existing feedback refs are not duplicated. - `f pr feedback` is full-context by default. Use `--compact` if you want the older terse terminal view. - `f pr feedback` now emits a Kit-ready handoff, so the same feedback set can drive a future review bot instead of staying trapped in GitHub comments. - `f pr open edit` remains the quickest path to tweak PR title/body from local editor. ================================================ FILE: docs/commands/publish.md ================================================ # f publish Publish projects to GitHub. ## Overview Creates a new GitHub repository and pushes the current project. Automatically infers the repo name from the folder name and handles both new repos and existing ones. ## Quick Start ```bash # Interactive mode - prompts for name and visibility f publish # Skip prompts, use folder name, default to private f publish -y # Create public repo f publish --public # Create with specific name f publish --name my-awesome-project ``` ## Options | Option | Short | Description | |--------|-------|-------------| | `--name <NAME>` | `-n` | Repository name (defaults to folder name) | | `--public` | | Create as public repository | | `--private` | | Create as private repository | | `--description <DESC>` | `-d` | Repository description | | `--yes` | `-y` | Skip prompts, use defaults (private, folder name) | ## Prerequisites - [GitHub CLI](https://cli.github.com/) installed (`gh`) - Authenticated with `gh auth login` ## Usage ### Interactive Mode Without flags, prompts for configuration: ```bash $ f publish Repository name [my-project]: Visibility (public/private) [private]: public Create repository: Name: username/my-project Visibility: public Proceed? [Y/n]: ``` ### Non-Interactive Mode ```bash # Use all defaults (private, folder name as repo name) f publish -y # Public repo with defaults f publish -y --public # Specific name and description f publish -n cool-tool -d "A cool CLI tool" --public ``` ### Existing Repositories If the repository already exists on GitHub: 1. Checks current visibility 2. Updates visibility if different from requested 3. Adds origin remote if missing 4. Pushes current branch ```bash $ f publish --public Repository username/my-repo already exists (private). Updating visibility to public... ✓ Updated to public ✓ https://github.com/username/my-repo ``` ## What Happens 1. **Check gh CLI** - Verifies GitHub CLI is installed 2. **Check authentication** - Ensures you're logged in to GitHub 3. **Get username** - Fetches your GitHub username 4. **Determine repo name** - Uses `--name`, folder name, or prompts 5. **Determine visibility** - Uses `--public`/`--private` or prompts 6. **Check if exists** - If repo exists, updates visibility if needed 7. **Initialize git** - If not a git repo, runs `git init` 8. **Create initial commit** - If no commits, stages all and commits 9. **Create repository** - Uses `gh repo create` with `--source=. --push` 10. **Output URL** - Prints the GitHub URL ## Examples ### Quick Publish New Project ```bash cd my-new-project f publish -y --public # ✓ Published to https://github.com/username/my-new-project ``` ### Publish with Description ```bash f publish -n api-server -d "REST API for my app" --private ``` ### Update Existing Repo Visibility ```bash # Repo exists as private, make it public f publish --public # Repository username/my-repo already exists (private). # Updating visibility to public... # ✓ Updated to public ``` ### In Scripts/CI ```bash #!/bin/bash cd /path/to/project f publish -y --public -n release-candidate ``` ## Troubleshooting ### "GitHub CLI (gh) is not installed" Install from https://cli.github.com: ```bash # macOS brew install gh # Linux sudo apt install gh # or equivalent ``` ### "GitHub authentication required" Run `gh auth login` and follow the prompts. ### "Could not determine GitHub username" Ensure you're authenticated: `gh auth status` ### Repository exists but can't update visibility Some visibility changes may require specific permissions or GitHub plan features. ## See Also - [deploy](deploy.md) - Deploy after publishing - [upstream](upstream.md) - Managing forks ================================================ FILE: docs/commands/readme.md ================================================ # Flow Commands Reference Complete documentation for all `f` (flow) commands. ## Quick Reference | Command | Description | |---------|-------------| | [`deploy`](deploy.md) | Deploy to Linux hosts, Cloudflare Workers, or Railway | | [`release`](release.md) | Publish a release to registry or GitHub | | [`publish`](publish.md) | Publish project to GitHub | | [`install`](install.md) | Install a CLI/tool via registry, parm, or flox | | [`clone`](clone.md) | Clone repositories with git-like destination behavior | | [`repos`](repos.md) | Clone repositories into ~/repos | | [`new`](new.md) | Create a project from a local template in ~/new | | [`commit`](commit.md) | AI-powered commit with code review | | [`pr`](pr.md) | Create/open PRs and ingest GitHub feedback | | [`upstream`](upstream.md) | Manage upstream fork workflow | | [`env`](env.md) | Sync project environment and manage env vars | | [`invariants`](invariants.md) | Validate project invariants from `flow.toml` | | [`fast`](fast.md) | Low-latency AI task invocation via fast client | | [`up`](up.md) | Bring a project up with lifecycle conventions | | [`down`](down.md) | Bring a project down with lifecycle conventions | | [`domains`](domains.md) | Shared local `*.localhost` route manager on port 80 | | [`tasks`](tasks.md) | List and run project tasks | | [`global`](global.md) | Run tasks from global flow config | | [`setup`](setup.md) | Print aliases or run setup task | | [`ai`](ai.md) | Manage AI coding sessions (Claude + Codex) | | [`daemon`](daemon.md) | Manage background daemons | | [`parallel`](parallel.md) | Run tasks in parallel | | [`docs`](docs.md) | Manage auto-generated documentation | | [`web`](web.md) | Open the Flow web UI for a project | | [`url`](url.md) | Inspect or crawl URLs into compact AI-friendly summaries | ## Getting Started ```bash # Show all commands f --help # Get help for a specific command f deploy --help f commit --help ``` ## Command Categories ### Deployment - **[deploy](deploy.md)** - Deploy to hosts and cloud platforms - **[release](release.md)** - Publish releases to registries - **[publish](publish.md)** - Publish project to GitHub ### Version Control - **[commit](commit.md)** - AI-powered commits with review - **[pr](pr.md)** - PR creation/editing plus review feedback ingestion - **[clone](clone.md)** - Clone with git-like destination behavior - **[repos](repos.md)** - Clone repos into a structured directory - **[upstream](upstream.md)** - Fork management and sync - **[fixup](fixup.md)** - Fix common TOML syntax errors ### Task Management - **[tasks](tasks.md)** - List project tasks - **[fast](fast.md)** - Run AI tasks through the low-latency fast client path - **[up](up.md)** - Start project lifecycle (`up`/`dev`) with optional domains setup - **[down](down.md)** - Stop project lifecycle with optional domains teardown - **[global](global.md)** - Run tasks from ~/.config/flow/flow.toml - **[setup](setup.md)** - Print aliases or run setup task - **[run](run.md)** - Run a specific task - **[parallel](parallel.md)** - Run tasks in parallel - **[rerun](rerun.md)** - Re-run last task - **[search](search.md)** - Fuzzy search global commands ### Process Management - **[ps](ps.md)** - List running flow processes - **[kill](kill.md)** - Stop running processes - **[logs](logs.md)** - View task logs - **[daemon](daemon.md)** - Manage background daemons ### AI & Development - **[ai](ai.md)** - Manage AI coding sessions - **[url](url.md)** - Inspect or crawl URLs into compact summaries for AI use - **[agent](agent.md)** - Invoke AI subagents - **[match](match.md)** - Match natural language to tasks - **[sessions](sessions.md)** - Search AI sessions across projects ### Environment & Configuration - **[env](env.md)** - Manage environment variables - **[invariants](invariants.md)** - Validate invariant policies in `flow.toml` - **[domains](domains.md)** - Shared local domain proxy ownership and route management - **[init](init.md)** - Scaffold a new flow.toml - **[doctor](doctor.md)** - Verify tools and integrations ### Project Management - **[new](new.md)** - Create a project from a local starter in `~/new` - **[projects](projects.md)** - List registered projects - **[active](active.md)** - Show or set active project - **[hub](hub.md)** - Ensure hub daemon is running ### Documentation - **[docs](docs.md)** - Manage auto-generated documentation - **[commits](commits.md)** - Browse commits with AI metadata ### Legacy Compatibility - **[recipe](recipe.md)** - Legacy recipe command (hidden; prefer `tasks` + `.ai/tasks/*.mbt`) ### Other - **[skills](skills.md)** - Manage Codex skills - **[install](install.md)** - Install binaries via registry/parm/flox - **[db](db.md)** - Manage databases and providers - **[tools](tools.md)** - Manage AI tools - **[notify](notify.md)** - Send proposal notifications - **[server](server.md)** - Start HTTP server for logs ## Global Options ```bash -h, --help Print help -V, --version Print version ``` ## Configuration Flow uses `flow.toml` for project configuration. See [flow.toml reference](../flow-toml.md) for full documentation. ## See Also - [Getting Started Guide](../getting-started.md) - [flow.toml Reference](../flow-toml.md) ================================================ FILE: docs/commands/recipe.md ================================================ # f recipe Legacy compatibility command. Preferred model: - Put shell tasks in `flow.toml` under `[[tasks]]`. - Put AI/native tasks in `.ai/tasks/*.mbt`. - Run via `f <task>` or `f ai:<selector>`. `f recipe` remains for older repos that still use `.ai/recipes`. ## Usage ```bash f recipe list # legacy listing f recipe run <selector> # legacy execution ``` ## Options - `--scope <project|global|all>`: recipe source scope (default `all`) - `--global-dir <PATH>`: override global recipes directory - `--cwd <PATH>` (run only): working directory for execution - `--dry-run` (run only): print command without executing ## Legacy Recipe Locations - Project recipes: `.ai/recipes/project` (fallback `.ai/recipes`). - Global recipes: `~/.config/flow/recipes`. Supported extensions: `.md`, `.markdown`, `.mbt` MoonBit recipe metadata is optional and can be declared in top comment lines: ```mbt // title: My Fast Recipe // description: Run a moonbit action quickly // tags: [moonbit, fast] ``` ## Migration ```bash # Old f recipe run project:my-recipe # New f tasks init-ai f ai:my-task ``` ================================================ FILE: docs/commands/release.md ================================================ # f release Release a project based on `flow.toml` defaults or explicit subcommands. ## Usage ```bash f release f release registry f release gh ``` ## Registry releases ```bash f release registry ``` ### flow.toml ```toml [release] default = "registry" versioning = "calver" [release.registry] url = "https://myflow.sh" package = "flow" bins = ["flow", "f", "lin"] default_bin = "flow" token_env = "FLOW_REGISTRY_TOKEN" latest = true ``` ### Options - `--version <VERSION>`: publish a specific version. - `--registry <URL>`: override the registry base URL. - `--bin <NAME>`: override the binaries to upload (repeatable). - `--no-build`: skip building binaries. - `--latest` / `--no-latest`: control latest pointer updates. ## GitHub releases ```bash f release gh ``` ================================================ FILE: docs/commands/repos.md ================================================ # f repos Clone repositories into a structured local directory or create new ones. If you want standard `git clone` destination behavior, use [`f clone`](clone.md) instead. ## Overview `f repos clone` clones GitHub repositories into `~/repos/<owner>/<repo>` using SSH URLs. By default it does a shallow clone for speed, then fetches full history in the background. It always sets up an `upstream` remote and local tracking branch unless you pass `--no-upstream`. `f repos create` creates a GitHub repository from the current folder and pushes it. By default, Flow treats `~/repos` as an immutable managed root. Use `FLOW_REPOS_ALLOW_ROOT_OVERRIDE=1` if you need to point `--root` somewhere else. ## Quick Start ```bash # Clone a GitHub repo into ~/repos/<owner>/<repo> f repos clone https://github.com/owner/repo # Short form f repos clone owner/repo # Skip upstream auto-setup f repos clone owner/repo --no-upstream # Full clone (skip background history fetch) f repos clone owner/repo --full # Create a new repo from the current folder (prompts for name/visibility) f repos create ``` ## Options ### f repos clone | Option | Short | Description | |--------|-------|-------------| | `<URL>` | | Repository URL or `owner/repo` | | `--root <PATH>` | | Root directory for clones (default: `~/repos`, override requires `FLOW_REPOS_ALLOW_ROOT_OVERRIDE=1`) | | `--full` | | Full clone (skip shallow clone + background history fetch) | | `--no-upstream` | | Skip upstream setup | | `--upstream-url <URL>` | `-u` | Upstream URL override (skips GitHub lookup) | ### f repos create | Option | Short | Description | |--------|-------|-------------| | `--name <NAME>` | `-n` | Repository name (defaults to current folder) | | `--public` | | Create as public repository | | `--private` | | Create as private repository | | `--description <TEXT>` | `-d` | Description for the repository | | `--yes` | `-y` | Skip confirmation prompts | ## Upstream Automation Flow will: 1. Query GitHub via `gh api repos/<owner>/<repo>` 2. Detect the parent repository 3. Run `f upstream setup --url <parent>` inside the cloned repo If the repo is not a fork (or `gh` is unavailable), flow sets `upstream` to the `origin` URL. ## Background History Fetch When cloning in fast mode (default), flow spawns a background fetch: - `git fetch --unshallow --tags origin` - `git fetch --tags upstream` (if upstream was configured) ## Examples ```bash # Clone into a custom root (requires override) FLOW_REPOS_ALLOW_ROOT_OVERRIDE=1 f repos clone https://github.com/owner/repo --root ~/work/repos # Override upstream manually f repos clone https://github.com/your-user/repo -u git@github.com:upstream-org/repo.git ``` ================================================ FILE: docs/commands/reviews-todo.md ================================================ # f reviews-todo Manage deferred deep-review todos for queued commits. This command is a workflow wrapper over `f commit-queue` for the fast-commit + deep-Codex-review loop. ## Quick Start ```bash # List pending deep-review todos (queued commits) f reviews-todo list # Run Codex deep review for all queued commits f reviews-todo codex --all # Inspect one queued review todo f reviews-todo show <commit-sha> # Approve all queued commits after issues are addressed f reviews-todo approve-all ``` ## Why use this - Keep `f commit` fast. - Batch expensive Codex reviews later. - Keep one place to track deep-review backlog. ## Notes - `codex --all` maps to `f commit-queue review --all`. - Queue entries live under `.ai/internal/commit-queue/`. - Review findings are still recorded into `.ai/todos/todos.json` and commit review reports. - If `[options].myflow_mirror = true` is enabled, queued Codex reviews from the quick path are mirrored to myflow as `commit_queue_review` events. - For a full speed-first operating loop in `~/code/myflow`, see [`../fast-commit-deep-review-loop.md`](../fast-commit-deep-review-loop.md). ================================================ FILE: docs/commands/seq-rpc.md ================================================ # `f seq-rpc` Native `seqd` RPC bridge for Flow. Use this when an agent/workflow needs OS-level actions and you want a typed, low-overhead path. This command talks to `seqd` over Unix socket directly from Rust (no `seq rpc` subprocess). ## Why this command exists - Keeps protocol handling in Rust. - Avoids shell output parsing drift. - Gives stable response envelope fields (`ok`, `op`, `dur_us`, ids). - Matches hard policy in `docs/seq-agent-rpc-contract.md`. ## Usage ```bash f seq-rpc [--socket PATH] [--timeout-ms 5000] [--pretty] <action> ... ``` Actions: - `ping` - `app-state` - `perf` - `open-app <name>` - `open-app-toggle <name>` - `screenshot <path>` - `rpc <op> [--args-json '{...}']` Common id fields (recommended on every call): - `--request-id` - `--run-id` - `--tool-call-id` Example: ```bash f seq-rpc open-app "Safari" \ --request-id req-42 \ --run-id run-a12 \ --tool-call-id tool-7 \ --pretty ``` ## Socket resolution 1. `--socket <path>` 2. `SEQ_SOCKET_PATH` 3. `SEQD_SOCKET` 4. `/tmp/seqd.sock` ## Output Prints JSON response envelope from `seqd`. On `ok=false`, command exits non-zero after printing the response JSON. ================================================ FILE: docs/commands/services.md ================================================ # f services Guided setup flows for third-party services. These commands prompt for required env vars, store them in cloud, and can optionally apply them to Cloudflare. ## Stripe ```bash f services stripe ``` ### Options - `--path <PATH>`: target project root (defaults to current directory). - `--environment <ENV>`: env store to write (default: flow.toml or `production`). - `--mode <test|live>`: Stripe mode (default: `test`). - `--force`: prompt even if keys are already set. - `--apply` / `--no-apply`: apply envs to Cloudflare after setup. ### What it prompts for The command inspects `flow.toml` `[cloudflare].env_keys` and asks for Stripe keys found there (fallback order): - `STRIPE_SECRET_KEY` - `STRIPE_WEBHOOK_SECRET` - `STRIPE_PRO_PRICE_ID` - `STRIPE_REFILL_PRICE_ID` - `VITE_STRIPE_PUBLISHABLE_KEY` ### Helpful Stripe sources - Secret/Publishable keys: Stripe Dashboard -> Developers -> API keys - Webhook signing secret: Stripe Dashboard -> Developers -> Webhooks (or `stripe listen --print-secret`) - Price IDs: Stripe Dashboard -> Products -> Price (starts with `price_`) ### Example ```bash cd ~/org/gen/new f services stripe --mode test --apply ``` ================================================ FILE: docs/commands/setup.md ================================================ # f setup Bootstrap the project if needed, generate a `flow.toml` if missing, then run the `setup` task or print shell aliases. ## Quick Start ```bash # Bootstrap if missing, generate flow.toml if missing, then run setup task or print aliases f setup # Configure host deployment (Linux) f setup deploy # Configure release hosting for server projects f setup release # Use a specific config file f setup --config ./flow.toml ``` ## Behavior - If the project is not bootstrapped, it runs the bootstrap flow (`.ai/`, `.gitignore`). - If `flow.toml` is missing, it prompts to generate `setup` + `dev` tasks (AI via `gen` if available, otherwise manual prompts). - If `flow.toml` already exists, Flow non-destructively appends missing Codex baseline sections (`[skills]`, `[skills.codex]`, commit skill gate, and Bun testing gate in Bun contexts). - After baseline upgrades, Flow triggers a Codex skills reload (respecting `[skills.codex].force_reload_after_sync`) so open sessions pick up changes immediately. - If `flow.toml` defines a `setup` task, `f setup` runs that task. - After the `setup` task exits, Flow re-reads `flow.toml`, re-syncs task skills to `.ai/skills`, and reloads Codex skills (when configured). This makes setup-generated task changes visible to Claude/Codex immediately. - Otherwise, it prints shell aliases from `[alias]` in `flow.toml`. - After successful completion, Flow writes a setup checkpoint to `.rise/setup.json` in the repo root. - `f setup deploy` adds a `[host]` section, creates a remote setup script, copies env templates, and optionally stores the deploy host. - `f setup release` detects server projects and offers Linux host deployment defaults. ## Options | Option | Description | |--------|-------------| | `--config <PATH>` | Path to `flow.toml` (default: `./flow.toml`) | | `TARGET` | Optional setup target (e.g., `deploy`, `release`) | ### Global Server Setup Defaults You can provide a server template in your global config at `~/.config/flow/flow.toml`: ```toml [setup.server] template = "~/infra/flow.toml" ``` Or inline host defaults: ```toml [setup.server.host] setup = """#!/usr/bin/env bash set -euo pipefail ...""" env_file = ".env.host" port = 3000 ``` ================================================ FILE: docs/commands/skills.md ================================================ # f skills Manage Codex/Claude skills for the current project. Skills live in `.ai/skills/` and are symlinked to `.codex/skills` and `.claude/skills` so active agent sessions can discover them. ## Core Commands ```bash # List local skills f skills # Create/edit/remove skills f skills new <name> -d "description" f skills edit <name> f skills remove <name> # Install curated skills f skills install <name> # Generate one skill per flow.toml task f skills sync # Force Codex app-server to rescan skills for this cwd f skills reload ``` ## Codex Tight Feedback Loop Use this loop when coding with Codex/Claude: 1. Make code changes. 2. Run focused tests quickly (in Bun repos: `bun bd test ...`). 3. Refresh skill context: ```bash f skills sync f skills reload ``` 4. Commit via quality gates: ```bash f commit ``` `f skills reload` is useful for already-open Codex sessions; it refreshes the app-server skill cache without creating a new session. ## `flow.toml` Settings ```toml [skills] sync_tasks = true install = ["quality-bun-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false ``` ## Built-in Default Skills Flow auto-materializes a small baseline set of project-local skills in `.ai/skills/`: - `env` - `quality-bun-feature-delivery` - `pr-markdown-body-file` These are symlinked into `.codex/skills` and `.claude/skills` and can be reloaded with: ```bash f skills reload ``` ### `skills.codex` fields - `generate_openai_yaml`: writes `.ai/skills/<task>/agents/openai.yaml` for task-synced skills. - `force_reload_after_sync`: after `f skills sync` or `f skills install`, force Codex app-server `skills/list` with `forceReload: true`. - `task_skill_allow_implicit_invocation`: default `policy.allow_implicit_invocation` value in generated `agents/openai.yaml`. ## Recommended Enforcement ```toml [commit.testing] mode = "block" runner = "bun" bun_repo_strict = true require_related_tests = true ai_scratch_test_dir = ".ai/test" run_ai_scratch_tests = true allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 20 [commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] quality-bun-feature-delivery = 2 ``` This blocks commits that skip required Bun-oriented testing/skill policy. ================================================ FILE: docs/commands/sync.md ================================================ # f sync Sync git repo: pull from tracking remote, merge upstream changes, optionally push. ## Overview Single command to bring a repository up to date. Pulls from the tracking branch, syncs upstream if configured (fork workflow), and optionally pushes. Works with both plain git and jj (jujutsu) colocated repos. ## Context Card Use this block when passing `f sync` behavior to another agent: - Default behavior: pull/sync only; push is off unless `--push`. - Defaults: `--stash=true`, `--fix=true`. - Modes: uses jj flow in healthy colocated workspaces, falls back to plain git when needed. - Push target: configured `[git].remote` first, then standard fallback behavior. - Clipboard output: synced commit list is copied only when remote commit ranges are detected (typically jj fetch path). - Conflict note: jj can finish with unresolved conflicts and prints `jj resolve` guidance. ## Quick Start ```bash # Pull latest from remote (no push by default) f sync # Pull and push f sync --push # Pull with rebase instead of merge f sync -r # Pull with rebase and push f sync -r --push ``` ## Options | Option | Short | Description | |--------|-------|-------------| | `--rebase` | `-r` | Use rebase instead of merge when pulling | | `--push` | | Push to configured git remote after sync (default: off) | | `--no-push` | | Skip pushing (legacy; already the default) | | `--stash` | `-s` | Auto-stash uncommitted changes (default: true) | | `--stash-commits` | | Stash local JJ commits to a bookmark before syncing (jj only) | | `--allow-queue` | | Allow sync even when commit queue is non-empty | | `--create-repo` | | Create origin repo on GitHub if it doesn't exist | | `--fix` | `-f` | Auto-fix conflicts using Claude (default: true) | | `--no-fix` | | Disable auto-fix | | `--max-fix-attempts <N>` | | Maximum auto-fix attempts (default: 3) | | `--allow-review-issues` | | Allow push even if P1/P2 review todos are open | | `--compact` | | Legacy noise-reduction flag (jj fetch output is already compact by default) | ## What Happens ### Step 1: Pre-flight checks 1. Detects if jj is available and healthy; falls back to git if not 2. Checks for unmerged files and resolves them (or prompts) 3. Handles in-progress rebase/merge 4. Stashes uncommitted changes if `--stash` is set ### Step 2: Pull from tracking branch Pulls from the tracking remote/branch (e.g. `origin/main`). If the branch has no tracking info but the push remote has a matching branch, auto-configures tracking. - With `--rebase`: runs `git pull --rebase` - Without: runs `git pull --no-rebase --no-edit` (merge without opening an editor) - Auto-resolves conflicts when `--fix` is enabled ### Step 3: Sync upstream If an `upstream` remote exists (fork workflow), fetches and merges upstream changes into the current branch. If no `upstream` remote but on a feature branch, syncs from `origin/<default-branch>` (e.g. `origin/main`) into the current branch. ### Step 4: Push (optional) Only when `--push` is passed: - Detects fork push targets (redirects to private fork remote if configured) - Checks review-todo gate (blocks if P1/P2 issues are open unless `--allow-review-issues`) - Skips push if the push remote equals upstream (read-only clone) - Creates repo with `--create-repo` if origin doesn't exist ### Step 5: Restore stash Restores auto-stashed changes if any were stashed in step 1. ### Step 6: Synced commit list + clipboard When remote commit ranges are discovered (typically during jj fetch), Flow prints a deduplicated list of newly synced commits (hash + subject) and copies that same list to your clipboard. If no synced commit ranges were detected, this section is skipped. Set `FLOW_NO_CLIPBOARD=1` to disable clipboard copy. ## JJ (Jujutsu) Support When a `.jj` directory is present and healthy, sync uses the jj flow instead of plain git. Falls back to git if: - Configured `git.remote` is not `origin`/`upstream` - Tracking remote is a custom remote - jj workspace is unhealthy or corrupt Use `--stash-commits` to bookmark local jj commits before syncing. jj fetch output is compact by default; synced commit details are emitted once at the end for clipboard use. ## Commit Queue Guard If the commit queue has pending entries and sync would rebase (rewriting SHAs), sync refuses to proceed. Use `--allow-queue` to override, or process the queue first with `f commit-queue list`. ## Configuration ### Push remote Set in `flow.toml` under `[git]`: ```toml [git] remote = "origin" # default push remote ``` ### Fork push When a fork push target is configured, sync redirects push to the fork remote automatically. ## Examples ```bash # Basic sync (pull only) f sync # Sync and push f sync --push # Rebase workflow f sync -r --push # Sync a fork (has upstream remote) f sync --push # Create missing origin and push f sync --push --create-repo # Skip auto-fix for conflicts f sync --no-fix # Allow sync with pending commit queue f sync --allow-queue ``` ## Troubleshooting ### "Unmerged files detected" Sync found files with unresolved merge conflicts. By default (`--fix`), it tries to auto-resolve. If that fails, resolve manually: ```bash git status # see conflicted files # edit and fix conflicts git add <files> f sync # retry ``` ### "Commit queue is not empty" Rebase-based sync can rewrite commit SHAs, breaking queued commits. Either: ```bash f commit-queue list # review the queue f sync --allow-queue # override the guard ``` ### "Remote unreachable" The push remote doesn't exist or auth/network failed. For missing origin: ```bash f sync --push --create-repo ``` ### jj corruption fallback If jj sync fails due to workspace/store issues, sync automatically retries with plain git. Fix jj with: ```bash jj git import # or if still broken: rm -rf .jj && jj git init --colocate ``` ### "Sync complete (jj) but conflicts remain" This means sync/rebase completed but conflict revisions still exist in jj. ```bash jj resolve ``` ## See Also - [upstream](upstream.md) - Manage upstream fork workflow - [commit](commit.md) - Commit changes - [jj](jj.md) - Jujutsu workflow helpers ================================================ FILE: docs/commands/tasks.md ================================================ # f tasks List and discover project tasks from: - `flow.toml` (`[[tasks]]`) - `.ai/tasks/*.mbt` (AI MoonBit tasks) You can run tasks directly with `f <task>`. ## Usage ```bash f tasks f tasks list f tasks dupes f tasks init-ai f tasks build-ai ai:flow/dev-check f tasks run-ai ai:flow/dev-check f tasks run-ai --daemon ai:flow/dev-check f tasks daemon start f tasks daemon status f tasks daemon stop f ai-taskd-launchd-install f ai-taskd-launchd-status cargo build --release -p ai-taskd-client --bin ai-taskd-client ./target/release/ai-taskd-client ai:flow/dev-check f install-ai-fast-client f fast ai:flow/dev-check f bench-ai-runtime --iterations 80 --warmup 10 f bench-ffi-boundary --iters 10000000 f bench-ffi-boundary --iters 10000000 --native-opt ``` ## AI Task Workflow Initialize a starter MoonBit task: ```bash f tasks init-ai ``` This creates: ```text .ai/tasks/starter.mbt ``` Run it: ```bash f starter f ai:starter ``` Add more tasks as `.mbt` files under `.ai/tasks/` and run by name or selector: ```bash f release-flow f ai:project/release-flow ``` ## Notes - Default execution mode is cached native binary: 1) `moon build --target native --release` once per content hash 2) run cached artifact from `~/Library/Caches/flow/ai-tasks/...` - Use `f tasks run-ai --no-cache ...` (or `FLOW_AI_TASK_RUNTIME=moon-run`) to force direct `moon run`. - Set `FLOW_AI_TASK_MODE=release` for release builds (`--release`). - Set `FLOW_AI_TASK_MODE=js` to run with JS target. - `f tasks daemon` runs a lightweight local `ai-taskd` over Unix socket for warm repeated runs. - For lowest invocation overhead, use the tiny client binary against the daemon: - `cargo build --release -p ai-taskd-client --bin ai-taskd-client` - `./target/release/ai-taskd-client ai:<selector>` - or `f install-ai-fast-client` then use `fai ai:<selector>` - This bypasses full `f` startup for hot-loop calls. - `fai` supports: - `--protocol msgpack|json` (msgpack default) - `--timings` (server-side phase timings) - `--batch-stdin` (pooled burst mode in one client process) - For stable startup/jitter, prefer always-on daemon via launchd: - `f ai-taskd-launchd-install` - `f ai-taskd-launchd-status` - `f ai-taskd-launchd-logs` - Automatic preference for latency-critical AI selectors: - Opt in with `FLOW_AI_TASK_FAST_CLIENT=1` (typically together with `FLOW_AI_TASK_DAEMON=1`). - Then `f` will auto-prefer fast client dispatch for AI tasks tagged `fast`, `latency`, `hot`, or `hotkey`. - Override selector matching with `FLOW_AI_TASK_FAST_SELECTORS` (comma-separated patterns, supports `*` prefix/suffix). - Override client binary with `FLOW_AI_TASK_FAST_CLIENT_BIN=/path/to/fai`. - `f recipe` still exists for legacy compatibility, but task-centric workflow is preferred. ================================================ FILE: docs/commands/up.md ================================================ # f up Bring a project up using lifecycle conventions. ## Quick Start ```bash # Run lifecycle up (tries task "up", then "dev") f up # Pass args through to the selected task f up -- --port 3001 ``` ## Behavior - Loads nearest `flow.toml` (or `--config` path). - If `[lifecycle.domains]` is configured: - runs `f domains add <host> <target> --replace` - runs `f domains up` (with configured engine when set) - Runs lifecycle task: - `[lifecycle].up_task` when configured - otherwise fallback order: `up`, then `dev` If no up task is found, command fails with guidance. ## Options | Option | Description | |--------|-------------| | `--config <PATH>` | Path to `flow.toml` (default: `./flow.toml`, searches upward when default is missing) | | `ARGS...` | Extra args passed to the selected lifecycle task | ## Recommended myflow config ```toml [lifecycle] up_task = "dev" [lifecycle.domains] host = "myflow.localhost" target = "127.0.0.1:3000" engine = "native" remove_on_down = false stop_proxy_on_down = false ``` ================================================ FILE: docs/commands/upstream.md ================================================ # f upstream Manage upstream fork workflow. ## Overview Set up and sync forks with their upstream repositories. Creates a local `upstream` branch to cleanly track the original repo, making merges easier. ## Quick Start ```bash # Set up upstream tracking f upstream setup --upstream-url https://github.com/original/repo # Pull latest from upstream f upstream pull # Full sync: pull, merge, push f upstream sync ``` ## Subcommands | Command | Description | |---------|-------------| | `status` | Show current upstream configuration | | `setup` | Set up upstream remote and local tracking branch | | `pull` | Pull changes from upstream into local 'upstream' branch | | `sync` | Full sync: pull upstream, merge to dev/main, push to origin | --- ## Setup Configure upstream tracking for a forked repository: ```bash # Basic setup f upstream setup --upstream-url https://github.com/original/repo # Specify branch (if not main) f upstream setup --upstream-url https://github.com/original/repo --upstream-branch master ``` ### Options | Option | Short | Description | |--------|-------|-------------| | `--upstream-url <URL>` | `-u` | URL of the upstream repository | | `--upstream-branch <BRANCH>` | `-b` | Branch name on upstream (default: auto-detected) | ### What Happens 1. Adds `upstream` remote pointing to original repo 2. Fetches upstream branches 3. Creates local `upstream` branch tracking the upstream's default branch 4. Stores configuration in `.git/config` --- ## Pull Pull latest changes from upstream into local `upstream` branch: ```bash # Pull into upstream branch f upstream pull # Pull and also merge into specific branch f upstream pull --branch main ``` ### Options | Option | Short | Description | |--------|-------|-------------| | `--branch <BRANCH>` | `-b` | Also merge into this branch after pulling | --- ## Sync Full sync workflow - pulls upstream, merges to your branch, and pushes: ```bash # Full sync (pull, merge, push) f upstream sync # Sync without pushing (for review first) f upstream sync --no-push ``` ### Options | Option | Description | |--------|-------------| | `--no-push` | Skip pushing to origin | ### What Happens 1. Stashes any uncommitted changes 2. Fetches latest from upstream 3. Updates local `upstream` branch 4. Merges upstream into your current branch (e.g., `main`) 5. Pushes to origin (unless `--no-push`) 6. Restores stashed changes ### Branch Detection Flow auto-detects the upstream default branch: - Checks `refs/remotes/upstream/HEAD` - Falls back to checking if `upstream/main` or `upstream/master` exists - Uses `main` as final fallback --- ## Status Show current upstream configuration: ```bash f upstream status ``` Output: ``` Upstream Configuration Remote: https://github.com/original/repo Branch: main Local tracking: upstream -> upstream/main Last sync: 2 hours ago ``` --- ## Workflow Example ### Initial Fork Setup ```bash # 1. Clone your fork git clone https://github.com/youruser/project cd project # 2. Set up upstream tracking f upstream setup --upstream-url https://github.com/original/project # 3. Verify f upstream status ``` ### Regular Sync ```bash # When you want to sync with upstream: f upstream sync # Or if you want to review before pushing: f upstream sync --no-push git log --oneline main..upstream # See what's new git push # Push when ready ``` ### Handling Conflicts If sync encounters merge conflicts: ```bash $ f upstream sync Merging upstream into main... CONFLICT (content): Merge conflict in src/lib.rs # Fix conflicts manually vim src/lib.rs git add src/lib.rs git commit # Then push git push ``` --- ## Configuration Upstream configuration is stored in `.git/config`: ```ini [remote "upstream"] url = https://github.com/original/repo fetch = +refs/heads/*:refs/remotes/upstream/* [branch "upstream"] remote = upstream merge = refs/heads/main ``` You can also manually configure: ```bash git remote add upstream https://github.com/original/repo git fetch upstream git branch upstream upstream/main ``` --- ## Troubleshooting ### "upstream remote not found" Run `f upstream setup` first with the upstream URL. ### "git stash pop failed" This can happen if there were no changes to stash. Flow handles this automatically by tracking stash state. ### Upstream uses master instead of main Flow auto-detects the default branch. If detection fails, specify explicitly: ```bash f upstream setup --upstream-url https://github.com/original/repo --upstream-branch master ``` ### Merge conflicts Resolve conflicts manually: 1. Fix conflicting files 2. `git add <files>` 3. `git commit` 4. `git push` ## See Also - [commit](commit.md) - Commit changes after sync - [publish](publish.md) - Publish to GitHub ================================================ FILE: docs/commands/url.md ================================================ # `f url` Inspect or crawl URLs into compact AI-friendly summaries. ## Quick Start ```bash # Thin single-page summary f url inspect https://developers.cloudflare.com/changelog/post/2026-03-10-br-crawl-endpoint/ # Force Cloudflare Browser Rendering markdown f url inspect --provider cloudflare https://developers.cloudflare.com/changelog/post/2026-03-10-br-crawl-endpoint/ # Machine-readable output f url inspect --json https://linear.app/fl2024008/project/llm-proxy-v1-6cd0a041bd76/overview # Explicit site crawl (Cloudflare Browser Rendering) f url crawl https://developers.cloudflare.com/browser-rendering/rest-api/crawl-endpoint/ --limit 3 --records 2 ``` ## `inspect` `f url inspect <url>` uses this provider order: 1. Cloudflare Browser Rendering markdown 2. configured scraper backend from `[skills.seq]` 3. direct fetch fallback Default output is intentionally compact: - title - URL - content type - short description - short excerpt Use `--full` to include the full markdown/content body. ## `crawl` `f url crawl <url>` is the explicit multi-page path. It currently uses Cloudflare Browser Rendering crawl and polls until the job completes or the wait timeout is reached. Useful flags: ```bash f url crawl <url> --limit 10 --records 5 f url crawl <url> --depth 2 --render f url crawl <url> --include-pattern "https://developers.cloudflare.com/browser-rendering/*" f url crawl <url> --exclude-pattern "*/changelog/*" f url crawl <url> --json ``` Defaults are tuned to stay small: - `--limit 10` - `--depth 2` - `--records 5` - `--render false` ## Auth Cloudflare auth is read from: 1. shell env 2. Flow personal env store fallback Required keys: - `CLOUDFLARE_ACCOUNT_ID` - `CLOUDFLARE_API_TOKEN` No daemon is required. ## Config If you have a local scraper backend, `f url inspect` reuses `[skills.seq]` settings from repo `flow.toml` or global `~/.config/flow/flow.toml`: ```toml [skills.seq] scraper_base_url = "http://127.0.0.1:7444" scraper_api_key = "..." cache_ttl_hours = 2 allow_direct_fallback = true ``` ================================================ FILE: docs/commands/web.md ================================================ # web Open the Flow web UI for the current project. ## Usage ```bash f web ``` ## What it does - Serves the `.ai/web` UI for the current project. - Exposes `/api/projects` for project metadata and top-level `.ai` entries. - Exposes `/api/ai` for the full `.ai` tree (paths + kinds). - Exposes `/api/sessions` for Claude/Codex session summaries and transcripts. - Exposes `/api/openapi` when an OpenAPI spec is detected. ## Options ```bash --host <host> Host to bind (default: 127.0.0.1) --port <port> Port to serve the UI on (default: 9310) ``` ## Notes - `f web` serves `.ai/web/dist` when it exists (Vite default output). - The Vite app source lives in `.ai/web`. - `.ai/web` is gitignored by default so AI can freely rewrite it. - If no build exists, `f web` shows a minimal placeholder. - Example build: `vite build` from `.ai/web`. - `f web` now runs `bun install` (once) and `bun run build` automatically when a Vite app is present. ================================================ FILE: docs/commits/.gitkeep ================================================ ================================================ FILE: docs/commits/readme.md ================================================ # Commit Explanations (Generated) `f explain-commits` writes generated artifacts into this folder by default: - `*.md`: human-readable commit explanations - `*.json`: machine-readable sidecars - `.index.json`: digest/index cache Policy: - This directory is treated as local generated output by default. - Generated files are gitignored to keep normal commits clean. - If you want tracked artifacts, set `[explain-commits].output_dir` in `flow.toml` to a different directory and commit that directory intentionally. ================================================ FILE: docs/dependency-vendoring.md ================================================ # Dependency Vendoring (Cargo-First) This project uses a Cargo-first vendoring model: - Cargo remains the resolver, lockfile authority, and build system. - Vendored source is owned in `nikivdev/flow-vendor`. - `flow` pins vendored state by commit in `vendor.lock.toml`. - `flow` uses `[patch.crates-io]` path overrides into `lib/vendor/*`. This gives direct dependency control without giving up Cargo behavior. ## Why This Model ### Problem - crates.io + transitive dependency growth hurts compile times and iteration speed. - upstream crates can pull convenience dependencies, macros, and features we do not need. - editing third-party code in-place inside the main repo pollutes history and makes updates hard. ### Requirements - keep Cargo benefits (resolver correctness, lock semantics, ecosystem compatibility), - gain direct control over dependency source and shape, - keep upstream sync fast and automatable, - keep repository history readable. ### Result - dependency source churn lives in `flow-vendor`, - application-level pin and wiring lives in `flow`, - updates are reproducible and lock-pinned, - trim/refactor opportunities are local and fast. ## Nix-Inspired Discipline This model borrows the parts of Nix that matter most for dependency control: - pinned inputs (`vendor.lock.toml`, `Cargo.lock`), - deterministic materialization (`vendor-repo.sh hydrate`), - provenance/checksum verification (`vendor-manifest`, strict verify), - transactional updates with rollback safety (`vendor-control.sh inhouse`), - closure-size reduction by trimming unused dependency surface. Reference: - `docs/vendor-nix-inspiration.md` ## Benefits - Faster local iteration by removing unneeded dependency surface area. - Ability to aggressively trim crates to exactly what `flow` uses. - Deterministic hydration in CI and local environments from a pinned vendor commit. - Clean `flow` history: metadata/pins in `flow`, source churn in `flow-vendor`. - Upstream updates remain scriptable and reviewable. ## Core Files and Their Roles - `vendor.lock.toml` - Source of truth for vendor remote, branch, checkout, pinned commit, and crate map. - `Cargo.toml` - `[patch.crates-io]` points selected crates to `lib/vendor/<crate>`. - `Cargo.lock` - Must resolve vendored crates by path (no registry source for vendored entries). - `lib/vendor/<crate>` - Materialized source tree used by Cargo path patches. - `lib/vendor-manifest/<crate>.toml` - Per-crate metadata for version/provenance/sync and verification. - `scripts/vendor/*` - Toolkit for inhouse, hydrate, status, sync, and vendor-repo operations. ## Repositories ### `flow` repo - owns pins, manifests, trim logic hooks, and Cargo wiring. - should not include full vendored source history churn. ### `flow-vendor` repo - canonical storage for vendored crate source (`crates/<crate>`), - vendored crate manifests (`manifests/<crate>.toml`), - profile metadata used during hydration. ## Operating Principle: Cargo First Do not replace Cargo. Use Cargo as the system of record: - resolve versions through `Cargo.lock`, - use `cargo update -p <crate> --precise <version>` for deterministic lock rewrites, - build and validate with normal Cargo commands (`cargo check`, `cargo test --no-run`), - use vendoring only as controlled source substitution via patches. ## Standard Workflow (One Crate) Recommended entrypoint: ```bash ~/code/rise/scripts/vendor-control.sh inhouse --project ~/code/flow <crate> [version] ``` What this does: 1. Ensures lock entry and Cargo patch wiring. 2. Materializes crate from Cargo cache into `lib/vendor/<crate>`. 3. Stores crate history in `lib/vendor-history/<crate>.git`. 4. Writes `lib/vendor-manifest/<crate>.toml` + `UPSTREAM.toml`. 5. Re-syncs `Cargo.lock` to exact vendored version. 6. Applies trim hooks (`scripts/vendor/apply-trims.sh`). 7. Imports local materialized source into `.vendor/flow-vendor`. 8. Pins `vendor.lock.toml` to new vendor commit. ## Verification and Safety Gates Run after each vendoring step: ```bash f update-deps --important f vendor-trims ~/code/rise/scripts/vendor-control.sh verify --project ~/code/flow python3 ./scripts/vendor/rough_edges_audit.py --project . --strict-warnings cargo check -q scripts/vendor/sync-all.sh --important --dry-run ``` For full dependency refresh (latest allowed by policy), run: ```bash f update-deps ``` Useful flags: ```bash f update-deps --dry-run f update-deps --no-major f update-deps --push-vendor ``` `verify` enforces: - crate exists in `vendor.lock.toml`, - crate exists in `Cargo.lock`, - no registry source for vendored crate in `Cargo.lock`, - one resolved version per vendored crate, - patch path matches lock materialized path, - manifest version matches lock version. `vendor-rough-audit --strict-warnings` additionally enforces warning-hygiene regressions for known vendored crate hot spots (`crossterm`, `portable-pty`, `x25519-dalek`, `ratatui`) so release builds stay quiet. ## Provenance and Hardening `inhouse` now records provenance fields in crate manifests: - `registry_index` - `cargo_registry_checksum` - `crate_archive_sha256` - `checksum_match` - `upstream_repository` - `upstream_homepage` - `history_head` Use report mode: ```bash ~/code/rise/scripts/vendor-control.sh provenance --project ~/code/flow ``` Use stricter mode when migrating fully: ```bash ~/code/rise/scripts/vendor-control.sh verify --project ~/code/flow --strict-provenance ``` ## Transactional Failure Behavior `vendor-control.sh inhouse` includes rollback protection by default: - snapshot relevant files before mutation, - on failure, restore pre-run `Cargo.toml`, `Cargo.lock`, `vendor.lock.toml`, - remove newly created manifest/source/history artifacts for failed crate, - restore prior vendor lock pin. Escape hatch (not recommended except debugging): ```bash ~/code/rise/scripts/vendor-control.sh inhouse --project ~/code/flow <crate> --no-rollback ``` ## Upstream Sync Loop Track updates: ```bash scripts/vendor/check-upstream.sh --important scripts/vendor/sync-all.sh --important --dry-run ``` Apply updates intentionally: ```bash scripts/vendor/sync-all.sh --important scripts/vendor/vendor-repo.sh import-local git -C .vendor/flow-vendor push origin main ``` Policy: - patch updates can be frequent, - minor/major updates happen in explicit review windows (`--allow-minor`, `--allow-major`). ## Code Intelligence Loop (opensrc-style, crates-focused) To make vendored code practical at scale, we index first-party + vendored sources into Typesense and query them fast during refactors and trim work. This follows the same high-level pattern as `opensrc`: - keep a local source inventory (`.vendor/typesense/sources.json`), - keep local source materialized (already done by vendor hydrate/inhouse), - index/search against local code state, not remote assumptions. Flow entrypoints: ```bash f vendor-typesense-setup # one-time if Typesense is not installed locally f vendor-typesense-up f vendor-code-index f vendor-code-search "Router" f vendor-code-search "serde" --scope vendor --crate axum f vendor-code-search-sources "ratatui" ``` Script used by tasks: ```bash python3 ./scripts/vendor/typesense_code_index.py --help ``` Design goals: - search by vendored crate boundary (`--crate <name>`), - search by ownership boundary (`--scope vendor|firstparty`), - keep source provenance in inventory (`version`, `checksum`, `history_head`), - make trim/upstream update work faster by removing "where is this code?" overhead. Reference: - `docs/vendor-code-intelligence.md` for architecture, commands, and operating loop. ## CI Contract CI must hydrate vendored source from `vendor.lock.toml` before Cargo build: ```bash scripts/vendor/vendor-repo.sh hydrate ``` Any CI build skipping hydrate can fail with missing `lib/vendor/*` path deps. ## Optimization Strategy (Compile-Time Focus) For each vendored crate: 1. inspect real usage in `flow` (APIs/types called), 2. remove optional features not used, 3. delete convenience-only dependencies, 4. remove proc-macro convenience layers where reasonable, 5. reduce duplicate major versions where possible, 6. keep trim hooks deterministic and replayable. Use: ```bash scripts/vendor/offenders.sh cargo tree -d ``` to rank impact and watch duplicate-version pressure. Operational tooling for this loop: - `f vendor-rough-audit` - `f vendor-offenders` - `f vendor-bench-iter -- --mode incremental --samples 3` - `f vendor-optimize-loop` Reference: - `docs/vendor-optimization-loop.md` ## Commit Policy - In `flow`: commit only lock/manifest/patch/docs/script changes. - In `flow-vendor`: commit source churn. - Push `flow-vendor` first, then push `flow` pin updates. - Prefer one crate per commit for auditability. ## Recovery Playbook Inspect state: ```bash scripts/vendor/vendor-repo.sh status ``` Re-hydrate local materialization from pinned commit: ```bash scripts/vendor/vendor-repo.sh hydrate ``` Re-pin to known commit: ```bash scripts/vendor/vendor-repo.sh pin <commit> ``` ## FAQ ### Are we replacing Cargo? No. Cargo remains central. Vendoring is an ownership layer on top. ### Why separate repo for vendored source? To keep main repo history focused on product changes while retaining full dependency source control. ### Can we still pull upstream changes quickly? Yes. `check-upstream` + `sync-*` + locked import flow is designed for repeatable upstream ingestion. ================================================ FILE: docs/dev-server-management.md ================================================ # Dev Server Management Flow's supervisor manages dev server lifecycle declaratively. Define servers in `config.ts`, Flow handles starting, stopping, port cleanup, and restart on failure. ## Config Chain ``` ~/config/i/lin/config.ts (source of truth: devServers array) ↓ lin daemon watches, runs `bun ./config.ts` ~/.config/flow/flow.toml (generated [[server]] entries) ↓ supervisor polls mtime every 2s supervisor (starts/stops/restarts processes) ↓ running processes (bash → rise dev, wrangler, etc.) ``` ## Lifecycle ### On Mac Reboot 1. macOS launchd starts the Flow supervisor (if installed via `f supervisor install --boot`) 2. Supervisor reads `~/.config/flow/flow.toml` 3. Starts daemons with `autostart = true` or `boot = true` 4. Servers with `autostart = false` wait for `f daemon start <name>` ### Starting Dev Servers After Reboot ```bash # See what's defined vs running f daemon status # Start a specific server f daemon start myflow-web # Start multiple f daemon start myflow-web && f daemon start myflow-api ``` ### Day-to-Day ```bash f daemon status # what's running f daemon start myflow-web # start f daemon stop myflow-web # stop f daemon restart myflow-web # restart f daemon logs myflow-web # view logs ``` ## How Servers Are Defined ### Source: `~/config/i/lin/config.ts` ```typescript const devServers = [ { name: "myflow-web", command: "bash", args: ["-c", "bash ./scripts/patch-rise-root.sh && cd web && RISE_WEB_PORT=3000 VITE_API_URL=http://localhost:8780 rise dev --root .. --platform web"], working_dir: "~/code/myflow", port: 3000, }, { name: "myflow-api", command: "bash", args: ["-c", "cd api/ts && npx wrangler dev --port 8780"], working_dir: "~/code/myflow", port: 8780, }, ] as const ``` ### Generated: `~/.config/flow/flow.toml` The lin config watcher runs `bun ./config.ts` which generates: ```toml [[server]] name = "myflow-web" command = "bash" args = ["-c", "bash ./scripts/patch-rise-root.sh && cd web && RISE_WEB_PORT=3000 VITE_API_URL=http://localhost:8780 rise dev --root .. --platform web"] working_dir = "~/code/myflow" port = 3000 autostart = false ``` ### Conversion: `[[server]]` → Daemon `ServerConfig::to_daemon_config()` in `src/config.rs` converts each server to a daemon with: - `restart = "on-failure"` (auto-restart on crash) - `retry = 3` (max 3 restart attempts) - `boot = false`, `autostop = false` ## `[[server]]` vs `[[daemon]]` - **`[[server]]`** — dev-time HTTP servers. Auto-get `restart = on-failure` + port eviction. - **`[[daemon]]`** — any long-running process. Full control over restart/boot/health. Both are managed the same way by the supervisor. ## Port Eviction Before starting any server, Flow kills any existing process on the target port: ``` lsof -ti :3000 | xargs kill ``` This prevents "port already in use" errors after crashes or unclean shutdowns. ## autostart vs boot vs on-demand | Field | When it starts | Use case | |-------|---------------|----------| | `autostart = true` | When supervisor starts | Always-on services (AI proxy, watchers) | | `boot = true` | On system boot only | System services | | Both false | `f daemon start <name>` | Dev servers (start when needed) | Dev servers default to `autostart = false` because you don't always need every project running. ## Supervisor The supervisor is the long-running process that manages all daemons. ```bash f supervisor status # is it running? f supervisor start # start it f supervisor install --boot # install launchd agent (survives reboot) ``` It polls `~/.config/flow/flow.toml` every 2 seconds. When the file changes: 1. Loads new config 2. Starts newly added daemons (if autostart) 3. Stops removed daemons 4. Restarts daemons whose config changed ## PID and Log Locations | What | Where | |------|-------| | PID files | `~/.config/flow/{name}.pid` | | Daemon logs | `~/.config/flow-state/daemons/{name}/stdout.log` | | Supervisor socket | `~/.config/flow-state/supervisor.sock` | ## Troubleshooting **Server shows "started" but port not responding:** - Dev servers need 10-20s to compile and start (patch → rise compile → vite) - Check with: `curl -sf http://localhost:3000 | head -5` - Check process tree: `pgrep -P $(cat ~/.config/flow/myflow-web.pid)` **"health check failed" warning on start:** - Normal for dev servers — they take time to boot. Flow will check again. - If it never comes up, check the command runs manually: ```bash cd ~/code/myflow && bash ./scripts/patch-rise-root.sh && cd web && RISE_WEB_PORT=3000 rise dev --root .. --platform web ``` **Server not in `f daemon status`:** - Check flow.toml has the entry: `grep myflow ~/.config/flow/flow.toml` - If missing, the lin daemon may be stopped: check `f daemon status` for `lin` - Regenerate manually: `cd ~/config/i/lin && bun ./config.ts` **Port not freed after removing a server:** - Check if `flow.toml` was regenerated - Check supervisor is running: `f supervisor status` - Manual cleanup: `lsof -ti :PORT | xargs kill` **Process restarting in a loop:** - Servers use `on-failure` restart with max 3 retries and exponential backoff (2s, 4s, 8s, ... 60s) - After 3 failures, supervisor gives up. Fix the issue and `f daemon restart <name>` ================================================ FILE: docs/env-security-roadmap.md ================================================ # Env Security Roadmap This document defines the hardening path for Flow env storage so it is usable in large orgs with strict secret-handling rules. ## Current State - `f env set KEY=VALUE` writes to personal scope. - `f env project set KEY=VALUE -e <env>` writes to project scope. - Personal cloud env values are still stored in Flow's server-managed secret store and fetched over authenticated API calls. - Project cloud env values are now sealed client-side before upload and decrypted locally after fetch. - Project cloud reads auto-register the local device sealer when needed. - Legacy plaintext project cloud values are still read as a compatibility fallback during migration. - Local env values live under `~/.config/flow/env-local/`. - On macOS, personal local env values are now stored in Keychain by default and Flow keeps only local references on disk. - Project-local envs still use private `.env` files on disk because apps and deploy flows often need direct file materialization. - Host deploys that still fetch project envs via service tokens keep an explicit plaintext cloud mirror until the host fetch path is upgraded. ## Security Goals - No tracked repo file should ever be required for secret storage. - Secret values should be encrypted or OS-protected at rest by default. - Metadata should be separable from secret material. - Cloud sharing must not require trusting the server with plaintext values. - Reads should be auditable and scoped. - Rotation and revocation must be first-class. ## Secret Model Use three classes: 1. Secret value Examples: API keys, signing material, service tokens. 2. Sensitive metadata Examples: project IDs, team IDs, URLs that should not be public broadly. 3. Non-secret metadata Examples: team key `IDE`, environment name, feature flags safe to commit. Rule: - Secret values belong in Flow env storage. - Non-secret metadata should prefer checked-in config. - Sensitive metadata can live in Flow env storage if the repo should not carry it. ## Immediate Policy For a Linear integration: - `DESIGNER_LINEAR_API_KEY` is a secret and should stay in Flow personal env storage. - `DESIGNER_LINEAR_TEAM_KEY=IDE` is not a secret and should move into forge config when the integration is wired. ## Phase 0 - Fix CLI/docs mismatches so users do not get fake command examples. - Make personal vs project scope explicit in docs and examples. - Enforce private local file permissions in code. ## Phase 1 - Use OS secure storage for local personal secrets by default. - Keep only local references or metadata on disk. - Add read gating for local secure-secret reads on macOS. - Add migration for legacy plaintext personal local env files. ## Phase 2 - Add explicit secret classification: - `secret` - `sensitive` - `public` - Store descriptions/metadata separately from values. - Add a local inspection command that shows where each key is stored without printing the value. ## Phase 3 Completed for project envs: - client-side envelope encryption for cloud-shared project values - reuse of Flow's existing sealing primitives used by SSH key storage - device/user recipient fanout based on registered project sealers - ciphertext-only project env storage on the server Still open: - group recipients - richer classification/policy enforcement at write time - better device recovery/re-share workflows - eliminating the temporary plaintext compatibility mirror for service-token host fetches ## Phase 4 - Add org-grade controls: - scoped service tokens - access logs - rotation workflows - revocation - break-glass recovery - policy checks for forbidden repo-local secret paths ## Constraints - Project envs that must become `.env` files for local runtime or deploys still need a materialization path. - Cloud sharing security should not regress existing deploy workflows. - Compatibility escape hatches are acceptable, but secure defaults must win. ================================================ FILE: docs/everruns-maple-runbook.md ================================================ # Everruns + Maple Runbook This is the fastest path to use the new Everruns telemetry export now. It sends `f ai everruns` traces to: - local Maple (dev visualization) - hosted Maple (shared/history visualization) ## What gets exported When enabled, Flow exports: - `everruns.tool_call` spans for each `seq_*` tool execution - runtime spans such as: - `everruns.tool_call_requested` - `everruns.output_message_completed` - `everruns.turn_failed` ## Prerequisites 1. `seqd` is running and reachable at your socket (`/tmp/seqd.sock` by default). 2. Everruns API is reachable (`http://127.0.0.1:9300/api` by default). 3. You have Maple ingest keys for local and/or hosted endpoint. ## 1) Configure env (now) From `~/code/flow`, set the endpoints + keys: ```bash f env set SEQ_EVERRUNS_MAPLE_LOCAL_ENDPOINT=http://ingest.maple.localhost/v1/traces f env set SEQ_EVERRUNS_MAPLE_LOCAL_INGEST_KEY=maple_pk_local_xxx f env set SEQ_EVERRUNS_MAPLE_HOSTED_ENDPOINT=https://ingest.1focus.ai/v1/traces f env set SEQ_EVERRUNS_MAPLE_HOSTED_INGEST_KEY=maple_pk_hosted_xxx ``` Optional tuning: ```bash f env set SEQ_EVERRUNS_MAPLE_QUEUE_CAPACITY=4096 f env set SEQ_EVERRUNS_MAPLE_MAX_BATCH_SIZE=128 f env set SEQ_EVERRUNS_MAPLE_FLUSH_INTERVAL_MS=50 f env set SEQ_EVERRUNS_MAPLE_CONNECT_TIMEOUT_MS=400 f env set SEQ_EVERRUNS_MAPLE_REQUEST_TIMEOUT_MS=800 ``` For optimized mirror (remote ClickHouse + durable local spool), also set: ```bash f env set SEQ_CH_MODE=mirror f env set SEQ_CH_MEM_PATH=~/.config/flow/rl/seq_mem.jsonl f env set SEQ_CH_LOG_PATH=~/.config/flow/rl/seq_trace.jsonl ``` ## 2) Run with env injected Use `f env run` so runtime sees configured values: ```bash f env run -- f ai everruns "open Safari and take a screenshot" ``` If you already export envs another way, this also works: ```bash f ai everruns "open Safari and take a screenshot" ``` On startup, if telemetry is enabled, Flow prints: `maple dual-ingest telemetry enabled` ## 3) Verify in Maple In Maple (local and hosted), filter by: - `service.name = seq-everruns-bridge` Look for span names: - `everruns.tool_call` - `everruns.tool_call_requested` - `everruns.output_message_completed` ## Troubleshooting 1. Error: `invalid SEQ_EVERRUNS_MAPLE_* configuration` - You set only endpoint or only key for local/hosted pair. - Fix by setting both or removing both for that pair. 2. Everruns command works but no spans in Maple - Confirm ingest endpoint includes `/v1/traces`. - Confirm ingest key is valid for that endpoint. - Confirm you ran through `f env run -- ...` (or equivalent env injection). 3. Temporary Maple outage - Tool execution continues. - Export is best-effort and non-blocking. ================================================ FILE: docs/everruns-seq-bridge-integration.md ================================================ # Everruns + Seq Bridge Integration This document describes the Flow integration that runs Everruns sessions and executes client-side `seq_*` tool calls via `seqd` without duplicating Seq mapping logic. ## Why This Was Added `f ai everruns` already existed, but duplicated three things now maintained in `~/code/seq`: - Everruns `seq_*` client-side tool catalog - tool-name normalization rules - request correlation ID shaping for seq RPC (`request_id`, `run_id`, `tool_call_id`) Flow now imports the shared bridge crate instead of carrying its own copy. ## What Changed Code path changed only in Everruns tool-bridge internals: - `src/ai_everruns.rs` - `Cargo.toml` dependency on `seq_everruns_bridge` Flow still owns and keeps unchanged: - Everruns prompt/session/message/event loop - Flow config/env resolution for Everruns (`[everruns]`, `FLOW_EVERRUNS_*`) - `f seq-rpc` command and other AI session commands Flow now additionally supports Maple dual-ingest telemetry export from the Everruns runtime when `SEQ_EVERRUNS_MAPLE_*` env vars are set. Runtime path is now SSE-first for lower latency: - primary: `GET /v1/sessions/{id}/sse` (push events, reconnect with `since_id`) - fallback: `GET /v1/sessions/{id}/events` polling when SSE endpoint is unavailable ## No-Overlap Contract This integration is intentionally scoped to avoid feature overlap: - Flow does not reimplement `seq_*` tool schema/mapping anymore. - Flow does not add a second Everruns runtime. - Existing `f ai claude` / `f ai codex` / `f seq-rpc` behavior remains unchanged. ## Maple Dual Ingest (Local + Hosted) When enabled, Flow emits: - tool call spans (`everruns.tool_call`) - runtime event spans (`everruns.tool_call_requested`, `everruns.output_message_completed`, etc.) using `seq_everruns_bridge::maple::MapleTraceExporter` and non-blocking background batching. Required env keys: - `SEQ_EVERRUNS_MAPLE_LOCAL_ENDPOINT` (example: `http://ingest.maple.localhost/v1/traces`) - `SEQ_EVERRUNS_MAPLE_LOCAL_INGEST_KEY` - `SEQ_EVERRUNS_MAPLE_HOSTED_ENDPOINT` (example: `https://ingest.1focus.ai/v1/traces`) - `SEQ_EVERRUNS_MAPLE_HOSTED_INGEST_KEY` Operational setup/run instructions: - `docs/everruns-maple-runbook.md` ## Dependency Setup Current local setup in `Cargo.toml`: ```toml seq_everruns_bridge = { path = "../seq/api/rust/seq_everruns_bridge" } ``` This matches a sibling checkout layout: - `~/code/flow` - `~/code/seq` ### Submodule Option (recommended for portability) If you want reproducible CI/clone behavior, replace the sibling path with a submodule path: 1. add seq as submodule (example): `third_party/seq` 2. update dep path to: ```toml seq_everruns_bridge = { path = "third_party/seq/api/rust/seq_everruns_bridge" } ``` ## Validation (Real Results) Run from `~/code/flow`: ```bash cargo check cargo run --release --bin f -- ai everruns --help rg "bridge_tool_definitions|parse_tool_call_requested|bridge_build_request" src/ai_everruns.rs rg "map_seq_operation|seq_client_tool_definitions" src/ai_everruns.rs rg "MapleTraceExporter|MapleSpan::for_runtime_event" src/ai_everruns.rs ``` End-to-end smoke (requires local Everruns + seqd): ```bash f ai everruns "ping" ``` Expected evidence of integration: - successful compile with shared bridge dependency - bridge call-sites present in Flow Everruns runtime - Maple exporter call-sites present (tool + runtime spans) - no duplicated tool catalog/mapping in `src/ai_everruns.rs` - SSE-first event consumption active in `src/ai_everruns.rs` ## When To Keep It / When To Revert Keep this integration if: - you want one source of truth for Everruns `seq_*` tool behavior - Flow and Seq should stay protocol-aligned with less maintenance drift Revert if: - Flow must build in environments where seq bridge path is unavailable - you intentionally want Flow and Seq to diverge in tool mapping behavior ================================================ FILE: docs/fast-commit-deep-review-loop.md ================================================ # Fast Commit + Deep Codex Review Loop This guide configures a speed-first commit workflow with deferred deep review: - Fast lane now: commit immediately with low-latency model fallbacks (GLM/Cerebras via `zerg/ai`). - Deep lane later: batch Codex reviews across queued commits. ## 1) Configure `~/code/myflow/flow.toml` ```toml [commit] queue = false queue_on_issues = false message_fallbacks = [ "rise:zai:glm-5", "rise:cerebras:gpt-oss-120b", "remote", "openai" ] review_fallbacks = [ "glm5", "rise:cerebras:gpt-oss-120b", "codex-high" ] [options] # Optional: mirror commit + queued-review results to myflow. myflow_mirror = true # myflow_url = "https://myflow.sh" # myflow_token = "..." # Optional: route Codex review through a wrapper transport binary. # Must implement `app-server` JSON-RPC compatibility. # codex_bin = "~/code/flow/scripts/codex-jazz-wrapper" ``` ## 2) Daily loop ```bash # Fast default commit path (quick lane) f commit # Later, run deep Codex review across backlog f reviews-todo codex --all # Inspect pending/updated deep-review todos f reviews-todo list # Approve once issues are addressed f reviews-todo approve-all ``` ## 3) Fixing attached issues without copy/paste - Each reviewed commit writes a report under `~/.flow/commits/`. - Use that report directly: ```bash f fix ~/.flow/commits/<report>.md ``` This replaces manual “copy `f commit` output into Codex and ask to address all issues”. ## Notes - Plain `f commit` already uses the fast lane (`--quick`) by default. Set `quick-default = false` only if you want blocking review as the default. - Async queued Codex reviews now emit `commit_queue_review` mirror sync events to myflow/gitedit when the reviewed commit is current `HEAD` (the default `f commit --quick` flow). - `f reviews-todo codex --all` is a workflow alias over commit queue deep review. ================================================ FILE: docs/features.md ================================================ # Flow Features Flow is a CLI tool for managing project tasks, AI coding sessions, and development workflows. ## Quick Reference | Command | Alias | Description | |---------|-------|-------------| | `f <task>` | - | Run a task directly | | `f search` | `f s` | Fuzzy search global tasks | | `f commit` | `f c` | AI-powered git commit | | `f commitWithCheck` | `f cc` | Commit with Codex code review | | `f ai` | - | Manage AI sessions (Claude/Codex) | | `f skills` | - | Manage Codex skills | | `f daemon` | `f d` | Manage background daemons | | `f env` | - | Manage environment variables | | `f match` | `f m` | Natural language task matching | --- ## Task Management ### Running Tasks ```bash # Run a task directly (most common usage) f <task-name> [args...] # Example: run 'dev' task with arguments f dev --port 3000 # Fuzzy search global tasks (outside project directories) f search f s ``` ### Task History ```bash # Show the last task input and output f last-cmd # Show full details of last task run f last-cmd-full # Re-run the last executed task f rerun ``` ### Process Management ```bash # List running flow processes for current project f ps f ps --all # List across all projects # Stop running processes f kill <task-name> f kill <pid> f kill --all ``` ### Task Logs ```bash # View logs from running or recent tasks f logs <task-name> f logs -f # Follow in real-time ``` ### Task Failure Hooks Flow can run a hook automatically when a task fails. This is useful for opening an AI prompt, collecting diagnostics, or running cleanup scripts. See `docs/task-failure-hooks.md` for configuration, environment variables, and default behavior. --- ## AI Session Management Manage Claude Code and Codex sessions with fuzzy search and session tracking. ### Listing Sessions ```bash # List all AI sessions for current project (Claude + Codex) f ai f ai list # List only Claude sessions f ai claude f ai claude list # List only Codex sessions f ai codex f ai codex list ``` ### Resuming Sessions ```bash # Resume a session (fuzzy search) f ai resume # Resume a specific session by name or ID f ai resume my-session # Resume Claude-only sessions f ai claude resume # Search Codex sessions globally by prompt text and resume the best match f ai codex find "make plan to get designer" # Search Codex sessions globally by prompt text and copy the best match f ai codex findAndCopy "make plan to get designer" # Narrow the Codex search to a repo path or workspace subtree f ai codex find --path ~/repos/acme/app "arranged tooling" ``` Important resume rules: - `f ai claude resume <explicit-id-or-name>` is strict (fails instead of opening a different session). - `f ai codex resume ...` requires an interactive TTY. - For full details, see `commands/ai.md`. ### Copying Session Content ```bash # Copy full session history to clipboard (fuzzy search) f ai copy # Copy last exchange (prompt + response) to clipboard f ai context # Copy last 3 exchanges from a specific project f ai claude context - /path/to/project 3 # Copy from a specific session f ai context my-session /path/to/project 2 ``` The `-` placeholder triggers fuzzy search for session selection. ### Saving & Managing Sessions ```bash # Save/bookmark a session with a name f ai save my-feature-work f ai save bugfix --id <session-id> # Open or create notes for a session f ai notes my-session # Remove a saved session from tracking f ai remove my-session # Initialize .ai folder structure f ai init # Import existing sessions for this project f ai import ``` --- ## AI-Powered Git Commits ### Standard Commit ```bash # Stage all changes, generate AI commit message, commit, and push f commit f c # Skip pushing after commit f commit --no-push ``` ### Commit with Code Review ```bash # Run Codex code review before committing f commitWithCheck f cc # Review checks for: # - Bugs # - Security vulnerabilities # - Performance issues # # Optional config: # [options] # commit_with_check_async = false # force local sync execution # commit_with_check_use_repo_root = false # only stage/commit from current subdir # commit_with_check_timeout_secs = 300 # abort review if it hangs (default 300) # commit_with_check_review_retries = 2 # retry timed-out review runs (default 2) # # Optional env overrides: # FLOW_COMMIT_WITH_CHECK_TIMEOUT_SECS=600 # FLOW_COMMIT_WITH_CHECK_REVIEW_RETRIES=3 # FLOW_COMMIT_WITH_CHECK_RETRY_BACKOFF_SECS=5 # If issues found, prompts for confirmation before proceeding ``` --- ## Background Daemons Manage long-running processes defined in `flow.toml`. ```bash # Start a daemon f daemon start <name> # Stop a daemon f daemon stop <name> # Check daemon status f daemon status # List available daemons f daemon list f daemon ls ``` Daemon config supports autostart, boot-only daemons, restart policies, and readiness checks: ```toml [[daemon]] name = "lin" binary = "lin" command = "daemon" args = ["--host", "127.0.0.1", "--port", "9050"] health_url = "http://127.0.0.1:9050/health" autostart = true autostop = true boot = true restart = "on-failure" retry = 3 ready_output = "ready" ready_delay = 500 ``` --- ## Environment Variables Manage environment variables via cloud integration. ### Authentication ```bash # Login to cloud f env login # Check auth status f env status ``` ### Managing Variables ```bash # Pull env vars to .env file f env pull f env pull -e staging # Push local .env to cloud f env push f env push -e production # Apply cloud envs to Cloudflare f env apply # Interactive setup (select env file + keys) f env setup f env setup -e staging -f .env.staging # List env vars f env list f env ls # Set a single variable f env set KEY=value f env set API_KEY=secret -e production # Delete variable(s) f env delete KEY1 KEY2 ``` --- ## Codex Skills Manage Codex skills stored in `.ai/skills/` (gitignored by default). Skills help Codex understand project-specific workflows. ### Managing Skills ```bash # List all skills f skills f skills ls # Create a new skill f skills new deploy-worker f skills new deploy-worker -d "Deploy to Cloudflare Workers" # Show skill details f skills show deploy-worker # Edit a skill in your editor f skills edit deploy-worker # Remove a skill f skills remove deploy-worker ``` ### Installing Curated Skills ```bash # Install from Codex skill registry f skills install linear f skills install github-pr ``` ### Syncing from flow.toml ```bash # Generate skills from flow.toml tasks f skills sync # Force Codex to rescan skills for the current cwd f skills reload ``` This creates a skill for each task in `flow.toml`, so Codex automatically knows about your project's workflows. To auto-sync tasks or auto-install curated skills on demand, add a `[skills]` section to `flow.toml`: ```toml [skills] sync_tasks = true install = ["quality-bun-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false ``` `[skills.codex]` keeps agent context tight by generating `agents/openai.yaml` for task skills and automatically refreshing Codex’s skill cache after sync/install. For strict local quality enforcement on commit: ```toml [commit.testing] mode = "block" runner = "bun" bun_repo_strict = true require_related_tests = true ai_scratch_test_dir = ".ai/test" run_ai_scratch_tests = true allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 20 [commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] quality-bun-feature-delivery = 2 ``` ### Fetching Dependency Skills via seq ```bash # Fetch by dependency name f skills fetch dep react # Auto-discover dependencies and fetch top N per ecosystem f skills fetch auto --top 3 # Fetch from URLs f skills fetch url https://docs.python.org/3/library/asyncio.html --name asyncio ``` Optional defaults in `flow.toml`: ```toml [skills.seq] seq_repo = "~/code/seq" out_dir = ".ai/skills" scraper_base_url = "http://127.0.0.1:7444" allow_direct_fallback = true top = 3 ecosystems = "npm,pypi,cargo,swift" ``` ### Skill Structure `.ai/skills/` is generated locally and should not be committed. ``` .ai/skills/ └── deploy-worker/ └── SKILL.md ``` Each `SKILL.md` contains: ```markdown --- name: deploy-worker description: Deploy to Cloudflare Workers --- # deploy-worker ## Instructions Run this task with `f deploy-worker` ## Examples ... ``` --- ## Natural Language Task Matching Match tasks using natural language via local LM Studio. ```bash # Match a query to a task f match "run the tests" f m "start development server" # Requires LM Studio running on localhost:1234 ``` --- ## Project Management ### Projects ```bash # List registered projects f projects # Show or set active project f active f active set my-project ``` ### Initialization ```bash # Create a new flow.toml in current directory f init # Fix common TOML syntax errors f fixup ``` `f init` now seeds a Codex-first baseline (`[skills]`, `[skills.codex]`, and commit skill-gate sections) so task sync + skill enforcement are enabled from day one. ### Health Check ```bash # Verify required tools and shell integrations f doctor ``` --- ## Hub (Background Daemon) The hub manages background task execution and log aggregation. ```bash # Ensure hub daemon is running f hub # Start the HTTP server for log ingestion f server ``` --- ## flow.toml Configuration ### Basic Task Definition ```toml [[tasks]] name = "dev" description = "Start development server" command = "npm run dev" [[tasks]] name = "test" description = "Run tests" command = "cargo test" dependencies = ["cargo"] ``` ### Task with File Watching (Auto-rerun) ```toml [[tasks]] name = "build" command = "cargo build" rerun_on = ["src/**/*.rs", "Cargo.toml"] rerun_debounce_ms = 300 ``` ### Daemons (Background Services) ```toml [[daemons]] name = "api" command = "cargo run --bin server" description = "API server" [[daemons]] name = "worker" command = "node worker.js" ``` ### Dependencies ```toml [deps] git = "git" node = "node" cargo = "cargo" ``` --- ## Shell Integration ### Direnv Integration Add to `.envrc` for automatic project daemon startup: ```sh if command -v flow >/dev/null 2>&1; then flow project start --detach >/dev/null 2>&1 fi ``` ### Aliases (Recommended) ```bash alias f="flow" ``` --- ## File Structure ``` ~/.config/flow/ ├── flow.toml # Global config └── config.toml # Flow settings ~/.flow/ └── projects/ # Per-project daemon data └── <hash>/ ├── pid └── logs/ <project>/ ├── flow.toml # Project tasks └── .ai/ ├── sessions/ │ └── claude/ │ └── index.json └── skills/ # Codex skills (gitignored, materialized locally) └── <skill-name>/ └── SKILL.md ``` ================================================ FILE: docs/flow-toml-spec.md ================================================ # flow.toml Specification Minimal schema for Flow CLI tasks and managed dependencies. Designed for easy refactors and LLM prompting. ## File Layout ```toml version = 1 name = "my-project" # optional human-friendly project name [deps] # optional: command deps or managed pkg specs # key = "cmd" # single command on PATH # key = ["cmd1","cmd2"] # multiple commands # key = { pkg-path = "ripgrep", version = "14" } # managed pkg descriptor [flox] # optional: install set for managed env (applies to all tasks) [flox.install] # name.pkg-path = "ripgrep" # name.version = "14.1.1" # name.pkg-group = "tools" # optional grouping # name.systems = ["x86_64-darwin"] # optional target systems # name.priority = 10 # optional ordering hint [[tasks]] # project tasks name = "setup" # required, unique command = "cargo check" description = "Compile workspace" # optional activate_on_cd_to_root = true # optional, default false dependencies = ["fast"] # optional, names from [deps] or [flox.install] shortcuts = ["s"] # optional aliases for task lookup [skills] # optional: skill enforcement (gitignored by default) sync_tasks = true # optional: generate skills for tasks install = ["linear"] # optional: ensure skills are installed (local ~/.codex/skills preferred, else registry) [skills.codex] # optional: Codex-specific skill metadata/reload behavior # generate_openai_yaml = true # force_reload_after_sync = true # task_skill_allow_implicit_invocation = false [codex] # optional: Codex-first open/resolve behavior # auto_resolve_references = true # prompt_context_budget_chars = 1200 # max_resolved_references = 2 [[codex.reference_resolver]] # name = "linear" # match = ["https://linear.app/*/issue/*", "https://linear.app/*/project/*"] # command = "my-linear-tool inspect {{ref}} --json" # inject_as = "linear" [skills.seq] # optional: seq-backed dependency skill fetching defaults # seq_repo = "~/code/seq" # out_dir = ".ai/skills" # scraper_base_url = "http://127.0.0.1:7444" # allow_direct_fallback = true [commit] # optional: commit workflow defaults # quick-default = false # optional override: make plain `f commit` run blocking review (`--slow`) # queue = false # keep fast path pushing by default # queue_on_issues = false # do not force queue-only flow on review issues # message-fallbacks = ["rise:zai:glm-5", "rise:cerebras:gpt-oss-120b", "remote", "openai"] # review-fallbacks = ["glm5", "rise:cerebras:gpt-oss-120b", "codex-high"] [task_resolution] # optional: nested-task disambiguation policy # preferred_scopes = ["mobile", "root"] # used when plain task name is ambiguous # warn_on_implicit_scope = true # print note when fallback routing is applied [task_resolution.routes] # dev = "mobile" # route ambiguous task name -> scope # test = "root" [lifecycle] # optional: `f up` / `f down` defaults # up_task = "dev" # default fallback order: up -> dev # down_task = "stop" # default fallback: down [lifecycle.domains] # optional: auto `f domains` wiring for `f up` / `f down` # host = "myflow.localhost" # target = "127.0.0.1:3000" # engine = "native" # "native" | "docker" # remove_on_down = false # stop_proxy_on_down = false [options] # optional: transport/runtime integrations # myflow_mirror = true # mirror commit + queue review events to myflow # myflow_url = "https://myflow.sh" # myflow_token = "..." # codex_bin = "/path/to/codex-wrapper" # must support `app-server` JSON-RPC # codex_bin = "~/code/flow/scripts/codex-jazz-wrapper" # You can set this in repo flow.toml or global ~/.config/flow/flow.toml [commit.testing] # optional: local commit-time test gate # mode = "warn" # "warn" | "block" | "off" # runner = "bun" # currently optimized for bun in Bun repos # bun_repo_strict = true # require_related_tests = true # ai_scratch_test_dir = ".ai/test" # run_ai_scratch_tests = true # allow_ai_scratch_to_satisfy_gate = false # max_local_gate_seconds = 20 [commit.skill_gate] # optional: require specific local skills before commit # mode = "warn" # "warn" | "block" | "off" # required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] # quality-bun-feature-delivery = 2 [invariants] # optional: AI-driven invariant enforcement # mode = "warn" # "warn" | "block" | "off" # architecture_style = "layered monorepo" # non_negotiable = ["no inline imports", "no any without justification"] # forbidden = ["git add -A", "git reset --hard"] [invariants.terminology] # "pi-ai" = "LLM abstraction layer" # "pi-agent" = "stateful agent runtime" [invariants.deps] # policy = "approval_required" # "approval_required" | "open" # approved = ["@sinclair/typebox", "@reatom/core"] [invariants.files] # max_lines = 300 [git] # optional: git remote defaults for commit/sync # remote = "origin" # e.g. "myflow-i" for contributor mirror repos [[alias]] # optional shell aliases (or use [aliases] table) fr = "f run" # key/value pairs of alias -> command [aliases] # optional table alternative to [[alias]] fr = "f run" ``` ## Semantics - `version`: currently `1`. - `name`: optional display name for the project (useful in history/metadata). - `[deps]`: map of dependency names to either: - string (single command to check on PATH), - string array (multiple commands), - table with `pkg-path` (+ optional `version`, `pkg-group`, `systems`, `priority`) for managed pkg. - `[flox.install]`: global managed packages always included when any task runs inside the managed env. - `tasks.dependencies`: names resolved against `[deps]` first, then `[flox.install]`. - Tasks run inside the managed env when any managed deps are present; otherwise they use host PATH. - `activate_on_cd_to_root`: tasks flagged run automatically when Flow is invoked via `activate` hooks. - `shortcuts`: case-insensitive aliases and abbreviations (auto-generated from task names) resolve tasks. - `alias`/`aliases`: emitted by `f setup` as shell `alias` lines. - `[skills]`: optional skill enforcement; `sync_tasks` generates `.ai/skills` from tasks and `install` ensures registry skills are present (skills are gitignored by default). - `[skills.codex]`: optional Codex tuning; task skill `agents/openai.yaml` generation, post-sync force reload, and implicit invocation policy defaults. - `[codex]`: optional Codex-first control-plane settings for `f codex open` / `f codex resolve`. - `auto_resolve_references`: when true, matched resolver output is compacted and injected into new-session prompts. - `prompt_context_budget_chars`: hard cap for injected context before the raw user request is appended. - `max_resolved_references`: maximum number of resolved references Flow may inject into one prompt. - `runtime_skills`: when true, `f codex open` may materialize Flow-managed per-launch runtime skills for wrapper transports. - `[[codex.reference_resolver]]`: repo-specific reference unrollers with wildcard `match` patterns and a shell `command` template. - command templates support `{{ref}}`, `{{query}}`, and `{{cwd}}`. - `f codex enable-global --full` writes the global wrapper/runtime baseline into `~/.config/flow/flow.toml` for you. - `[skills.seq]`: optional defaults for `f skills fetch ...` (local seq scraper integration). - `[commit]`: optional commit workflow defaults; plain `f commit` uses fast commit + deferred Codex deep review by default. Set `quick-default = false` to make plain `f commit` run blocking review instead. - `[task_resolution]`: optional policy for nested task discovery (`f <scope>:<task>`, preferred scopes, and per-task routes when plain names collide). - `[lifecycle]`: optional project boot/shutdown orchestration for `f up` and `f down`; can auto-wire shared local domain routes via `[lifecycle.domains]`. - `f up` task fallback order: `up`, then `dev` (unless `up_task` is set). - `f down` task fallback: `down`; if missing and `down_task` is unset, Flow falls back to project-wide `f kill --all`. - `[options]`: optional integration/runtime toggles; use `myflow_mirror` for mirror sync and `codex_bin` to route review calls through a wrapper transport. - `[commit.testing]`: optional local testing gate evaluated during `f commit`; supports Bun-first strict mode plus optional AI scratch-test fallback (`.ai/test` by default). - `[commit.skill_gate]`: optional required-skill policy for `f commit`; can enforce presence and minimum skill versions. - `[invariants]`: optional policy checks for forbidden patterns, dependency allowlists, terminology context, and file-size limits. `mode = "block"` makes invariant warnings fail `f invariants` and commit-time invariant gate checks. - `[git].remote`: preferred writable remote used by `f commit`/`f sync --push` (and jj remote defaults). Fallback order is `[git].remote`, then legacy `[jj].remote`, then `origin`. ## Notes - Unsupported keys are ignored or will error; keep to this schema. - Managed env tooling currently assumes `flox` is installed. - Paths in commands are executed via `/bin/sh -c` in the config’s directory unless overridden. ## Codex-First Baseline Use this baseline when optimizing for Codex/Claude sessions and tight feedback loops: ```toml [skills] sync_tasks = true install = ["quality-bun-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false [codex] auto_resolve_references = true prompt_context_budget_chars = 900 max_resolved_references = 1 runtime_skills = true [[codex.reference_resolver]] name = "linear" match = ["https://linear.app/*/issue/*", "https://linear.app/*/project/*"] command = "my-linear-tool inspect {{ref}} --json" inject_as = "linear" [options] codex_bin = "~/code/flow/scripts/codex-flow-wrapper" [commit.testing] mode = "block" runner = "bun" bun_repo_strict = true require_related_tests = true ai_scratch_test_dir = ".ai/test" run_ai_scratch_tests = true allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 20 [commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] quality-bun-feature-delivery = 2 ``` ================================================ FILE: docs/how-api-expects-logs-errors-for-automatic-fixes.md ================================================ # Error Log Format for Automatic Fixes The flow server streams errors via SSE for automatic fix agents to consume. Structure your error logs with rich context to enable effective automatic fixes. ## Endpoints | Endpoint | Method | Description | |----------|--------|-------------| | `/logs/ingest` | POST | Ingest single or batch logs | | `/logs/query` | GET | Query logs with filters | | `/logs/errors/stream` | GET | SSE stream of new errors | ## Error Log Schema ```typescript interface ErrorLog { project: string // Project identifier (e.g., "web", "api", "cli") content: string // Error message - be descriptive timestamp: number // Unix timestamp in milliseconds type: "error" // Must be "error" for fix agents service: string // Service/component name (e.g., "auth", "database") stack?: string // Stack trace - critical for automatic fixes format: "text" | "json" } ``` ## Best Practices for Automatic Fixes ### 1. Include Full Stack Traces Stack traces are essential for locating the error source: ```typescript // Good - includes file, line, column { "content": "TypeError: Cannot read property 'email' of undefined", "stack": "TypeError: Cannot read property 'email' of undefined\n at getUser (/app/src/services/user.ts:42:15)\n at handleRequest (/app/src/routes/api.ts:18:10)", ... } // Bad - no stack trace, agent can't locate the error { "content": "TypeError: Cannot read property 'email' of undefined", ... } ``` ### 2. Use Absolute File Paths Prefer absolute paths in stack traces: ``` at getUser (/Users/dev/myapp/src/services/user.ts:42:15) ✓ at getUser (src/services/user.ts:42:15) ✓ (relative ok) at getUser (user.ts:42:15) ✗ (ambiguous) ``` ### 3. Descriptive Error Messages Include context in the error message: ```typescript // Good "Failed to parse user response: expected 'email' field but got undefined. Input: {id: 123, name: 'test'}" // Bad "undefined error" ``` ### 4. Structured JSON Format (Optional) For complex errors, use `format: "json"` with structured content: ```typescript { "project": "api", "content": JSON.stringify({ "error": "ValidationError", "message": "Invalid user data", "field": "email", "received": null, "expected": "string", "context": { "endpoint": "/api/users", "method": "POST", "requestId": "abc123" } }), "timestamp": Date.now(), "type": "error", "service": "validation", "stack": "...", "format": "json" } ``` ## Error Categories the Fix Agent Handles | Category | Example | Auto-Fix Capability | |----------|---------|---------------------| | `TypeError` | Cannot read property 'x' of undefined | High - adds optional chaining | | `ReferenceError` | x is not defined | Medium - suggests imports | | `SyntaxError` | Unexpected token | Low - needs manual review | | `Import errors` | Cannot find module 'x' | High - suggests npm install | | `Validation` | Invalid field type | Medium - adds type guards | | `Connection` | ECONNREFUSED | Low - infrastructure issue | ## Sending Errors from Your App ### TypeScript/JavaScript ```typescript interface ErrorPayload { project: string content: string timestamp: number type: "error" service: string stack?: string format: "text" | "json" } async function reportError(error: Error, service: string) { const payload: ErrorPayload = { project: process.env.PROJECT_NAME || "unknown", content: error.message, timestamp: Date.now(), type: "error", service, stack: error.stack, format: "text" } await fetch("http://127.0.0.1:9060/logs/ingest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) } // Global error handler process.on("uncaughtException", (error) => { reportError(error, "process") }) // Express/Hono middleware app.use((err, req, res, next) => { reportError(err, "http") res.status(500).json({ error: "Internal error" }) }) ``` ### React Error Boundary ```typescript class ErrorBoundary extends React.Component { componentDidCatch(error: Error, info: React.ErrorInfo) { fetch("http://127.0.0.1:9060/logs/ingest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project: "web", content: error.message, timestamp: Date.now(), type: "error", service: "react", stack: error.stack + "\n\nComponent Stack:\n" + info.componentStack, format: "text" }) }) } } ``` ## Consuming the Error Stream Connect to the SSE endpoint to receive errors in real-time: ```typescript const events = new EventSource("http://127.0.0.1:9060/logs/errors/stream") events.onmessage = (e) => { const error = JSON.parse(e.data) console.log(`New error in ${error.project}/${error.service}:`, error.content) // Trigger fix agent attemptFix(error) } events.onerror = (e) => { console.error("SSE connection error:", e) } ``` ## Testing 1. Start the flow server: ```bash f server ``` 2. Send a test error: ```bash curl -X POST http://127.0.0.1:9060/logs/ingest \ -H "Content-Type: application/json" \ -d '{ "project": "test", "content": "TypeError: Cannot read property '\''foo'\'' of undefined", "timestamp": '$(date +%s000)', "type": "error", "service": "test", "stack": "TypeError: Cannot read property '\''foo'\'' of undefined\n at test (/app/src/index.ts:10:5)", "format": "text" }' ``` 3. Watch the stream: ```bash curl -N http://127.0.0.1:9060/logs/errors/stream ``` ================================================ FILE: docs/how-flow-daemon-manages-macos-services.md ================================================ # How Flow Daemon Manages macOS Services Flow provides a declarative alternative to macOS launchd for managing background services. Instead of scattered `.plist` files, all services are defined in a single `flow.toml` configuration. ## Why Use Flow Instead of launchd? | launchd | Flow | |---------|------| | Plist files scattered across ~/Library/LaunchAgents | Single `~/.config/flow/flow.toml` | | Services auto-start on login (hidden cost) | Explicit control: autostart or on-demand | | Hard to audit what's running | `f daemon status` shows everything | | XML format, verbose | TOML format, readable | | No easy way to temporarily disable | `f daemon stop <name>` | ## Quick Start ```bash # See what's running f daemon status # Start a service f daemon start glm4 # Stop a service f daemon stop glm4 # Audit macOS launchd services f macos audit # Disable a launchd service and migrate to Flow f macos disable com.example.service # Then add [[daemon]] entry to flow.toml ``` ## Migrating from launchd to Flow ### Step 1: Audit Your Current Services ```bash # List all non-Apple launchd services f macos list # See what's currently running f macos status # Get recommendations f macos audit ``` ### Step 2: Get Service Details ```bash # View plist contents for a service f macos info com.example.service ``` This shows the binary, arguments, working directory, and environment variables needed to recreate the service in Flow. ### Step 3: Disable launchd Service ```bash f macos disable com.example.service ``` This runs `launchctl bootout` and `launchctl disable` to prevent the service from starting. ### Step 4: Add to flow.toml ```toml [[daemon]] name = "example" binary = "/path/to/binary" args = ["--port", "8080"] working_dir = "/path/to/workdir" port = 8080 health_url = "http://127.0.0.1:8080/health" autostart = false # true = starts on login boot = false # true = starts on system boot restart = "on-failure" # "never", "on-failure", "always" description = "Example service" # Optional: environment variables [daemon.env] API_KEY = "secret" ``` ### Step 5: Start the Service ```bash f daemon start example ``` ## Daemon Configuration Reference ### Required Fields | Field | Description | |-------|-------------| | `name` | Unique identifier for the daemon | | `binary` | Path to executable | ### Optional Fields | Field | Default | Description | |-------|---------|-------------| | `command` | - | Subcommand to run (e.g., "server") | | `args` | `[]` | Arguments passed to binary/command | | `working_dir` | - | Working directory for the process | | `port` | - | Port the service listens on | | `host` | `127.0.0.1` | Host the service binds to | | `health_url` | - | URL to check if service is healthy | | `autostart` | `false` | Start automatically when Flow starts | | `boot` | `false` | Start on system boot | | `autostop` | `false` | Stop when leaving project | | `restart` | - | Restart policy: `never`, `on-failure`, `always` | | `retry` | - | Max restart attempts | | `ready_delay` | - | Ms to wait before considering ready | | `ready_output` | - | Output pattern to match for readiness | | `description` | - | Human-readable description | | `env` | `{}` | Environment variables | ## Example Configurations ### Local LLM Server (On-Demand) ```toml [[daemon]] name = "glm4" binary = "/path/to/venv/bin/python" args = ["-m", "mlx_lm.server", "--model", "mlx-community/Qwen2.5-7B-Instruct-4bit", "--port", "8080"] working_dir = "/path/to/mlx-lm" port = 8080 health_url = "http://127.0.0.1:8080/health" autostart = false description = "MLX local LLM server" ``` ### File Watcher (Always Running) ```toml [[daemon]] name = "watchman" binary = "/opt/homebrew/bin/watchman" args = ["--foreground", "--logfile=/path/to/log"] autostart = true boot = true restart = "on-failure" description = "Facebook Watchman file watcher" ``` ### Node.js Service with Environment ```toml [[daemon]] name = "api" binary = "/path/to/node" args = ["/path/to/server.js"] working_dir = "/path/to/project" port = 3000 health_url = "http://127.0.0.1:3000/health" autostart = false restart = "on-failure" retry = 3 description = "API server" [daemon.env] NODE_ENV = "production" DATABASE_URL = "postgres://localhost/db" ``` ## macOS Service Audit Configuration Configure which services are allowed or should be blocked in your `flow.toml`: ```toml [macos] # Services matching these patterns won't be flagged allowed = [ "com.nikiv.*", "com.github.facebook.watchman", "limit.maxfiles", ] # Services matching these patterns will be recommended for removal blocked = [ "com.google.*", # Google updaters "com.adobe.*", # Adobe background services "us.zoom.*", # Zoom daemon "com.microsoft.update.*", "com.dropbox.*", "com.spotify.webhelper", ] ``` ## Commands Reference ### Daemon Management ```bash f daemon status # Show all daemon status f daemon start <name> # Start a daemon f daemon stop <name> # Stop a daemon f daemon restart <name> # Restart a daemon f daemon logs <name> # View daemon logs ``` ### macOS Service Audit ```bash f macos list [--user] [--system] [--json] # List launchd services f macos status # Show running non-Apple services f macos audit [--json] # Audit with recommendations f macos info <service> # Show service details f macos disable <service> [-y] # Disable a service f macos enable <service> # Re-enable a service f macos clean [--dry-run] [-y] # Disable known bloatware ``` ## Best Practices 1. **Start with audit**: Run `f macos audit` to see what's running unnecessarily 2. **Disable bloatware first**: Run `f macos clean` to disable known bloatware 3. **Migrate essential services**: Add services you need to `flow.toml` 4. **Use autostart sparingly**: Only set `autostart = true` for truly essential services 5. **Set health URLs**: Enables Flow to verify services are actually running 6. **Use restart policies**: `restart = "on-failure"` for production services ## Troubleshooting ### Service Won't Start ```bash # Check if binary exists ls -la /path/to/binary # Try running manually /path/to/binary --args # Check logs f daemon logs <name> ``` ### launchd Service Still Running ```bash # Force disable f macos disable <service> -y # Verify launchctl list | grep <service> # Manual removal if needed launchctl bootout gui/$(id -u)/<service> launchctl disable gui/$(id -u)/<service> ``` ### Finding the Right Binary Path ```bash # Get details from plist f macos info <service> # Or manually plutil -convert json -o - ~/Library/LaunchAgents/<service>.plist | jq ``` ================================================ FILE: docs/how-to-make-a-project-flow-project.md ================================================ # How To Make A Project A Flow Project This guide is for both: - a brand-new repository - an existing repository that already has scripts/tooling The goal is to make `flow.toml` the project control plane so local development, AI workflows, quality gates, and deploy operations all run through `f`. --- ## What "Flow Project" Means A project is Flow-managed when it has: 1. A `flow.toml` at repo root. 2. Core workflows exposed as `[[tasks]]` in `flow.toml`. 3. Secrets/environment managed by `f env` instead of committed `.env` files. 4. Commits made through `f commit` with explicit quality/testing policy. 5. Optional AI task and skills wiring (`.ai/tasks`, `.ai/skills`, `[skills]`). If your team can run `f tasks`, `f <task>`, `f env`, and `f commit` end-to-end from repo root, the project is Flow-native. --- ## Step 0: Machine Prerequisites On each developer machine: ```bash f doctor f auth login f latest ``` Why: - `f doctor` catches missing tooling and shell issues early. - `f auth login` enables cloud-backed features (`f env`, remote flows). - `f latest` avoids inconsistent command behavior across team members. --- ## Step 1: Bootstrap The Repo From repository root: ```bash cd /path/to/repo f setup ``` `f setup` will: - bootstrap project metadata (`.ai/`, `.gitignore` integration) - generate `flow.toml` if missing - append missing Codex baseline sections in existing `flow.toml` files - run `setup` task if one exists If `flow.toml` does not exist yet, `f setup` is the fastest way to initialize safely. --- ## Step 2: Define A Strong `flow.toml` Baseline Use this as a practical starter and replace commands with your real project commands. ```toml version = 1 name = "my-project" [skills] sync_tasks = true install = ["quality-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false [[tasks]] name = "setup" command = "echo 'project setup checks here'" description = "Prepare local environment and verify prerequisites" [[tasks]] name = "dev" command = "npm run dev" description = "Start local development server" [[tasks]] name = "test" command = "npm test" description = "Run test suite" [[tasks]] name = "test-related" command = "npm run test:related" description = "Run smallest useful tests for current diff" [[tasks]] name = "lint" command = "npm run lint" description = "Lint source code" [[tasks]] name = "build" command = "npm run build" description = "Build production artifacts" [commit] review_instructions_file = ".ai/commit-review-instructions.md" [commit.testing] mode = "block" require_related_tests = true max_local_gate_seconds = 30 [commit.skill_gate] mode = "block" required = ["quality-feature-delivery"] [invariants] mode = "warn" architecture_style = "layered architecture with task-based workflows" non_negotiable = [ "do not bypass Flow tasks for standard workflows", "keep user-visible changes documented" ] forbidden = ["git reset --hard", "git add -A .env"] [invariants.files] max_lines = 600 ``` Notes: - Keep task names short and stable (`dev`, `test`, `build`, `deploy`) so AI sessions stay consistent. - Prefer one canonical task per workflow. Avoid duplicate aliases with different behavior. --- ## Step 3: Migrate Existing Scripts Into Tasks If you already have scripts in `package.json`, `Makefile`, shell scripts, or CI configs: 1. Map each workflow to one Flow task in `flow.toml`. 2. Keep script internals if needed, but make `f <task>` the public entrypoint. 3. Update docs/README to reference `f` commands first. Example mapping: - `npm run dev` -> `[[tasks]] name = "dev"` - `make test` -> `[[tasks]] name = "test"` - `./scripts/release.sh` -> `[[tasks]] name = "release"` This prevents "works on my machine" command drift. --- ## Step 4: Move Secrets To `f env` Do not commit secrets in repo files. Use project-scoped env values: ```bash f env project set -e dev API_KEY=sk-... f env project set -e production API_KEY=sk-... f env list -e dev f env get -e production API_KEY -f value ``` Run commands with injected env: ```bash f env run -e dev -- f dev ``` If your project deploys to Cloudflare, declare env policy in `flow.toml` and use `f env apply` / `f deploy cf`. --- ## Step 5: Make Commit Quality Non-Optional Once baseline tasks and env are ready, enforce commit behavior: ```bash f commit ``` Recommended: - `f commit` for normal flow (fast commit + deferred deep review) - `f commit --slow` when you want blocking review before commit Policy lives in `flow.toml`: - `[commit.testing]` for local test gate - `[commit.skill_gate]` for required skills - `[invariants]` for project-specific guardrails Treat `--skip-tests` / `--skip-quality` as emergency-only. --- ## Step 6: Add AI Tasks (Optional, High Leverage) Initialize AI task pack: ```bash f tasks init-ai f tasks list f ai:starter ``` This creates `.ai/tasks/*.mbt` entries that can be run like normal tasks. Use this for: - structured automation - repo-specific AI workflows - low-latency repeatable helper routines --- ## Step 7: Wire Deploy Through Flow Choose one deploy target in `flow.toml`: - `[host]` for Linux SSH deploys - `[cloudflare]` for Workers - `[railway]` for Railway Then run: ```bash f deploy ``` Avoid ad hoc deploy commands in docs/CI when equivalent `f deploy` flow exists. --- ## Step 8: Update Team And CI Entry Points Make team defaults explicit: 1. README Quick Start uses `f setup`, `f dev`, `f test`, `f commit`. 2. CI scripts call Flow tasks (`f test`, `f build`) instead of bespoke command chains. 3. Contributors run from repo root to avoid task resolution surprises. Simple CI shape: ```bash f setup f test f build ``` --- ## Validation Checklist Run this from repo root: ```bash f setup f tasks list f test f env list -e dev f commit --dry ``` You are done when: 1. `flow.toml` defines all core workflows as tasks. 2. Secrets are in `f env`, not committed files. 3. `f commit` runs with your intended testing/quality policy. 4. New contributors can get productive with `f setup` + `f tasks list`. 5. CI and deploy flows run through Flow entrypoints. --- ## Common Migration Mistakes 1. Keeping parallel command paths (`npm run ...` in docs and `f ...` in flow): pick Flow as canonical. 2. Defining tasks without descriptions: hurts discoverability for humans and AI. 3. Leaving commit policy at defaults: set `[commit.testing]`, `[commit.skill_gate]`, and invariants intentionally. 4. Treating `flow.toml` as static: update it whenever workflow changes. --- ## Recommended Next Reads - `docs/flow-toml-spec.md` - `docs/commands/setup.md` - `docs/commands/tasks.md` - `docs/commands/env.md` - `docs/commands/commit.md` - `docs/how-to-use-flow-to-deploy.md` ================================================ FILE: docs/how-to-use-flow-ai-to-manage-claude-code-sessions.md ================================================ # Managing Claude and Codex Sessions with Flow Flow treats Claude and Codex as first-class coding runtimes in the same project loop: tasks, sessions, skills, and commit gates all live together. ## Core Workflow ```bash # 1) Enter repo and inspect runnable workflows cd <repo> f tasks list # 2) Resume exact prior AI context f ai claude resume <session-id> # or f ai codex resume <session-id> # 3) Keep skills synced to current tasks f skills sync f skills reload # 4) Run the smallest meaningful validation f test-related # 5) Commit through flow's review/testing gates f commit ``` This is the fastest way to keep context stable and avoid drift across sessions. ## Session Operations ```bash # Fuzzy-select and resume (all providers) f ai # Provider-specific listing f ai claude list f ai codex list # Resume by exact ID / prefix / alias f ai claude resume a38cf8bf-f4e2-4308-8b27-0254f89c4385 f ai codex resume 019c61c5-0aef-71a1-b058-5c9ab43013d4 f ai resume my-feature # Search Codex sessions globally by prompt text and resume the best match f ai codex find "make plan to get designer" # Search Codex sessions globally by prompt text and copy the best match f ai codex findAndCopy "make plan to get designer" # Save alias f ai save my-feature --id <session-id> # Copy context/history for handoff f ai context f ai copy ``` ## Resume Semantics You Should Rely On ### Exact Claude resumes are strict When you pass an explicit session (`name` or `id`), Flow will not auto-open a different conversation if that ID fails. - tries `claude --resume <id>` - if failed, exits non-zero - no automatic `--continue` fallback for explicit IDs This prevents restoring into the wrong session. ### Claude no-arg resume can fallback `f ai claude resume` (no argument) resumes the most recent Claude session for this repo. If that fails, Flow can fallback to `claude --continue` in the same cwd. ### Codex resume is direct Flow resumes Codex by session ID and returns failure if resume fails. No fallback to another session is applied. ### TTY is required Both Claude and Codex resume commands require an interactive terminal (TTY). In non-interactive shells, Flow fails fast with a clear error. ## Choosing Claude vs Codex in Flow - Use Claude sessions when you want broader planning + deep repo narrative continuity. - Use Codex sessions when you want tight code-edit and review loops with strong tool execution. - Keep both available in the same repo; switch by resuming the exact session you need. ## Task-Driven AI Coding (Important) AI sessions are most reliable when code execution goes through `flow.toml` tasks. If a task prompts for input (like `Y/n/a/q` workflows), mark it: ```toml [[tasks]] name = "reclaim" command = "./mole reclaim" interactive = true ``` This keeps TTY passthrough correct for both humans and AI-assisted loops. ## Related Docs - `commands/ai.md` for command-level semantics and examples - `commands/skills.md` for skill sync/reload loop - `commands/commit.md` for commit quality/testing gates - `use-flow-to-write-software-better.md` for the full operating model ================================================ FILE: docs/how-to-use-flow-to-deploy.md ================================================ # How to Deploy with Flow Flow provides a unified `f deploy` command to deploy your projects to Linux hosts, Cloudflare Workers, or Railway. ## Quick Start ```bash # Set up your deployment target (one-time) f deploy set-host root@your-server.com:22 # Add [host] config to your flow.toml (see below) # Deploy f deploy ``` ## Deployment Targets Flow auto-detects the deployment target from your `flow.toml`: - `[host]` → Linux server via SSH - `[cloudflare]` → Cloudflare Workers - `[railway]` → Railway ## Linux Host Deployment ### Prerequisites - SSH access to your server (key-based auth recommended) - `rsync` installed locally - Server should have: systemd, nginx (optional), certbot (for SSL) ### Configuration ```toml [host] dest = "/opt/myapp" # Where to deploy on server setup = """ cargo build --release """ run = "/opt/myapp/target/release/server" # Command to start service port = 3000 # Port your app listens on service = "myapp" # Systemd service name env_file = ".env.production" # Local .env to copy to server domain = "myapp.example.com" # Public domain (optional) ssl = true # Enable Let's Encrypt SSL ``` ### Setup Flow ```bash # Configure target host (stored in ~/.config/flow/deploy.json) f deploy set-host root@your-server.com:22 # Verify connection f deploy shell ``` ### Deploy ```bash # Full deployment f deploy # Or explicitly f deploy host # Force re-run setup script f deploy host --setup ``` ### What Happens 1. **Sync files** via rsync (excludes: target/, .git/, node_modules/) 2. **Copy .env** from `env_file` to server 3. **Run setup** script (only on first deploy or with `--setup`) 4. **Create systemd service** with your `run` command 5. **Configure nginx** reverse proxy (if `domain` specified) 6. **Set up SSL** via certbot (if `ssl = true`) 7. **Start/restart** the service ### Management Commands ```bash f deploy status # Check if service is running f deploy logs # View recent logs f deploy logs -f # Follow logs in real-time f deploy restart # Restart the service f deploy stop # Stop the service f deploy shell # SSH into the server ``` ## Cloudflare Workers ### Prerequisites - Wrangler CLI: `npm install -g wrangler` - Authenticated: `wrangler login` ### Configuration ```toml [cloudflare] path = "worker" # Path to worker directory env_file = ".env.cloudflare" # Secrets to set env_source = "cloud" # Use cloud as env source (optional) env_keys = ["API_KEY"] # Keys to fetch from cloud (optional) env_vars = ["APP_BASE_URL"] # Keys to set as non-secret vars (optional) environment = "staging" # Optional wrangler environment deploy = "wrangler deploy" # Custom deploy command (optional) dev = "wrangler dev" # Custom dev command (optional) ``` ### Setup (TUI) ```bash # Interactive Cloudflare setup (detects wrangler config + env files) f deploy setup ``` ### Deploy ```bash # Deploy to production f deploy cf # Set secrets and deploy f deploy cf --secrets # Run in dev mode f deploy cf --dev ``` ### Secrets If you specify `env_file`, flow will set each variable as a Cloudflare secret: ```env # .env.cloudflare API_KEY=secret123 DATABASE_URL=postgres://... ``` ```bash f deploy cf --secrets # Sets API_KEY and DATABASE_URL via `wrangler secret put` ``` If you set `env_source = "cloud"`, flow will fetch env vars from cloud instead of a local file: ```bash f env apply ``` If `environment` is set, Flow appends `--env <name>` for secrets and deploys. ## Railway ### Prerequisites - Railway CLI: `npm install -g @railway/cli` - Authenticated: `railway login` ### Configuration ```toml [railway] project = "your-project-id" # Railway project ID environment = "production" # Environment name env_file = ".env.railway" # Environment variables ``` ### Deploy ```bash f deploy railway ``` ## Examples ### Rust Server ```toml [host] dest = "/opt/api" setup = """ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source ~/.cargo/env cargo build --release """ run = "/opt/api/target/release/server" port = 8080 service = "api-server" env_file = ".env.production" domain = "api.example.com" ssl = true ``` ### Node.js App ```toml [host] dest = "/opt/webapp" setup = """ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt-get install -y nodejs npm ci --production npm run build """ run = "node /opt/webapp/dist/server.js" port = 3000 service = "webapp" env_file = ".env" domain = "app.example.com" ssl = true ``` ### Python/FastAPI ```toml [host] dest = "/opt/api" setup = """ apt-get install -y python3 python3-pip python3-venv python3 -m venv venv ./venv/bin/pip install -r requirements.txt """ run = "/opt/api/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000" port = 8000 service = "fastapi" env_file = ".env" ``` ### Cloudflare Worker with Hono ```toml [cloudflare] path = "worker" env_file = ".env.cf" ``` ```bash # In worker/ directory, have wrangler.toml: # name = "my-api" # main = "src/index.ts" f deploy cf --secrets ``` ## Tips ### Multiple Environments Use different env files for staging vs production: ```bash # Deploy to staging FLOW_ENV=staging f deploy # Or use different flow.toml sections (coming soon) ``` ### CI/CD Integration ```yaml # GitHub Actions - name: Deploy run: | echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 f deploy set-host ${{ secrets.DEPLOY_HOST }} f deploy ``` ### Viewing Deployed Service ```bash # Check status f deploy status # View logs f deploy logs -n 200 # Follow logs f deploy logs -f # SSH in for debugging f deploy shell ``` ### Rollback Currently manual - SSH in and use git: ```bash f deploy shell cd /opt/myapp git checkout HEAD~1 systemctl restart myapp ``` ## Troubleshooting ### "No host configured" ```bash f deploy set-host user@host:port ``` ### "Permission denied" Ensure SSH key is set up: ```bash ssh-copy-id user@host ``` ### "nginx: command not found" Install nginx on the server: ```bash f deploy shell apt-get install -y nginx ``` ### "certbot: command not found" Install certbot for SSL: ```bash f deploy shell apt-get install -y certbot python3-certbot-nginx ``` ### Service won't start Check logs: ```bash f deploy logs f deploy shell journalctl -u myservice -e ``` ================================================ FILE: docs/how-to-use-flow-to-store-and-work-with-env.md ================================================ # How to Use Flow Env Store (Project + Personal) This is the context-optimized workflow for current Flow behavior. ## Important Scope Rule - `f env set KEY=VALUE` writes to **personal** env scope. - `f env project set KEY=VALUE` writes to **project** env scope. If you need deploy/runtime envs for a project, use `f env project ...`. ## Backend Choice Flow supports: - `cloud` (myflow.sh): shared/team-friendly. - `local` (`~/.config/flow/env-local/`): offline/no-account. Cloud behavior: - Project env values are sealed client-side before upload and decrypted locally on read. - Personal cloud values still use the existing server-managed secret store. - If a host deploy still relies on `env_source = "cloud"` plus a service token, Flow keeps a compatibility plaintext mirror for those project keys until the host fetch path is upgraded. Set backend in `~/.config/flow/config.ts`: ```ts export default { flow: { env: { backend: "cloud" } } // or "local" } ``` Or per shell: ```bash export FLOW_ENV_BACKEND=local ``` ## Fast Path (Project Env, Production) ```bash # 1) Login once if using cloud f env login # 2) Set project env vars (not personal) f env project set DATABASE_URL=postgres://... -e production f env project set RESEND_API_KEY=re_... -e production # 3) Verify (masked) f env project list -e production # 4) Run app with injected project envs f env run -e production -- pnpm start # 5) Optional: write current project envs to local .env f env pull -e production ``` ## Personal Tokens (User Scope) Use for developer-specific tokens (GitHub, CLI auth, etc.): ```bash f env set GITHUB_TOKEN=ghp_... f env get --personal GITHUB_TOKEN -f value f env run --personal -k GITHUB_TOKEN -- gh auth status ``` Deepgram example (keep value in Flow store, never in docs): ```bash # Set once (personal scope) f env set --personal DEEPGRAM_API_KEY=<redacted> # Read when needed f env get --personal DEEPGRAM_API_KEY -f value ``` On macOS with the `local` backend, personal env values are stored in Keychain by default and Flow keeps only references under `~/.config/flow/env-local/personal/`. Use `FLOW_ENV_LOCAL_PLAINTEXT=1` only as a compatibility escape hatch. ## Environment Names Supported environments: - `production` (default) - `staging` - `dev` Example: ```bash f env project set API_URL=https://staging.example.com -e staging f env run -e staging -- pnpm dev ``` ## Guided Flows Use these when `flow.toml` already declares required keys: ```bash f env keys f env guide -e production f env setup ``` ## Deploy Integration ### Cloudflare Workers In `flow.toml`: ```toml [cloudflare] env_source = "cloud" # or "local" for local backend reads env_keys = ["DATABASE_URL", "BETTER_AUTH_SECRET", "APP_BASE_URL"] env_vars = ["APP_BASE_URL"] # non-secret vars environment = "production" ``` Apply: ```bash f env apply ``` ### Host Deploys In `flow.toml`: ```toml [host] env_source = "flow" # or "local" env_keys = ["DATABASE_URL", "RESEND_API_KEY"] environment = "production" ``` Then: ```bash f deploy ``` ## Local Backend Storage Layout When backend is `local`, Flow uses this layout: ``` ~/.config/flow/env-local/ ├── <project-or-space>/ │ ├── production.env │ ├── staging.env │ └── dev.env └── personal/ └── production.env ``` Behavior: - Project-local envs remain private `.env` files on disk. - On macOS, personal-local env values are Keychain-backed by default; the file stores Flow-managed references instead of raw secret values. - Flow writes local env dirs/files with owner-only permissions. ## Quick Troubleshooting - "Not logged in": run `f env login` (or force local backend). - Values not found: check environment (`-e staging` vs `production`). - Command injection not working: keep `--` before command. - Correct: `f env run -k API_KEY -- node app.js` - Wrong: `f env run -k API_KEY node app.js` ================================================ FILE: docs/ideas.toml ================================================ # f = fuzzy search through commands (builtin and project) # f <cmd> = does command [[alias]] fe = "f dev" # dev fd = "f deploy" # deploy fa = "f ai" # ai chat fc = "f commit" # commit ================================================ FILE: docs/index.mdx ================================================ --- title: Docs --- # Flow Docs High-signal docs for the Codex/Claude workflow: - [`commands/ai.md`](commands/ai.md): exact Claude/Codex session behavior (`resume`, TTY requirements, strict-ID semantics). - [`commands/skills.md`](commands/skills.md): `f skills sync`, `f skills reload`, and Codex skill metadata behavior. - [`commands/commit.md`](commands/commit.md): commit-time testing + skill-gate enforcement. - [`commands/invariants.md`](commands/invariants.md): invariant policy checks (`f invariants`) and commit-time enforcement behavior. - [`commands/fast.md`](commands/fast.md): low-latency AI task dispatch (`f fast`) via fast daemon client path. - [`commands/seq-rpc.md`](commands/seq-rpc.md): native `seqd` RPC bridge (`f seq-rpc`) for OS-level agent actions. - [`ci-cd-runbook.md`](ci-cd-runbook.md): CI/CD architecture, runner-mode operations, and failure debug checklist for canary/release pipelines. - [`install-script-latest-release-verification.md`](install-script-latest-release-verification.md): exact runbook for proving `curl -fsSL https://myflow.sh/install.sh | sh` installs the current latest stable release. - [`rise.md`](rise.md): full integration guide for installing and operating Rise via Flow (`f install rise`) across adopt/sync/dev/mobile/schema/sandbox workflows. - [`commands/domains.md`](commands/domains.md): shared local `*.localhost` proxy ownership via `f domains` (prevents per-repo port-80 collisions). - [`commands/clone.md`](commands/clone.md): git-like cloning (`f clone`) with GitHub URL/shorthand normalization to SSH, without forcing `~/repos`. - [`commands/up.md`](commands/up.md): one-command project startup (`f up`) with optional lifecycle domain setup. - [`commands/down.md`](commands/down.md): one-command project shutdown (`f down`) with lifecycle teardown rules. - [`commands/reviews-todo.md`](commands/reviews-todo.md): fast-commit + deferred Codex deep-review backlog workflow. - [`fast-commit-deep-review-loop.md`](fast-commit-deep-review-loop.md): recommended speed-first workflow for `~/code/myflow` (GLM/Cerebras fast lane + batched Codex deep reviews). - [`how-to-use-flow-ai-to-manage-claude-code-sessions.md`](how-to-use-flow-ai-to-manage-claude-code-sessions.md): practical session workflow for Claude + Codex in one repo loop. - [`codex-first-control-plane-roadmap.md`](codex-first-control-plane-roadmap.md): concrete plan for making Flow the Codex-first control plane with warm app-server daemon, intent aliases, reference unrollers, and runtime skills. - [`codex-openai-session-resolver.md`](codex-openai-session-resolver.md): app-server-backed `L <query>` resolver for repo-scoped Codex session resume in `~/repos/openai/codex`. - [`codex-fork-tasks.md`](codex-fork-tasks.md): personal Codex fork automation tasks for `nikiv` sync, scoped worktree/session start, last-session reattach, and review-branch promotion. - [`session-history-mining.md`](session-history-mining.md): efficient cross-project session mining (`f sessions`) and prompt scaffolds for token-sensitive planning. - [`session-semantic-recovery-with-seq.md`](session-semantic-recovery-with-seq.md): recover lost Claude/Codex work after resets using Seq's zvec-backed semantic session search plus `f ai ... resume`. - [`env-security-roadmap.md`](env-security-roadmap.md): hardening plan for Flow env storage, local keychain-backed secrets, and future end-to-end encrypted sharing. - [`how-to-make-a-project-flow-project.md`](how-to-make-a-project-flow-project.md): detailed onboarding and migration playbook for turning any repo into a Flow-native project. - [`use-flow-to-write-software-better.md`](use-flow-to-write-software-better.md): full end-to-end operating model for building software with Flow + Claude/Codex. - [`usage-analytics-rollout.md`](usage-analytics-rollout.md): exact patch order for opt-in anonymous usage tracking. - [`flow-toml-spec.md`](flow-toml-spec.md): complete config schema including `[skills.codex]`, `[commit.testing]`, and `[commit.skill_gate]`. - [`local-domains-no-random-ports.md`](local-domains-no-random-ports.md): stable `*.localhost` local domains via a lightweight proxy, plus `flow.toml` task wiring. - [`local-domains-domainsd-cpp-spec.md`](local-domains-domainsd-cpp-spec.md): native C++ `domainsd` architecture, rollout, and reliability constraints for low-latency localhost routing. - [`myflow-localhost-runbook.md`](myflow-localhost-runbook.md): exact commands to run `~/code/myflow` on `myflow.localhost` and use in-app `/processes` and `/logs` views. - [`seq-agent-rpc-contract.md`](seq-agent-rpc-contract.md): hard interface contract for agent OS actions through `seq_client -> seqd` RPC v1. - [`everruns-seq-bridge-integration.md`](everruns-seq-bridge-integration.md): why/how `f ai everruns` now reuses Seq's shared Everruns bridge to avoid duplicate tool mapping logic. - [`everruns-maple-runbook.md`](everruns-maple-runbook.md): exact commands to enable Everruns -> Maple dual-ingest and verify spans in local + hosted Maple. - [`ascii-commit-visualization-pipeline.md`](ascii-commit-visualization-pipeline.md): end-to-end pipeline for commit analysis -> Flow API -> `box-of-rain` ASCII/SVG diagrams in myflow. - [`features.md`](features.md): practical command flows and examples. - [`moonbit-ai-tasks-implementation.md`](moonbit-ai-tasks-implementation.md): complete inventory of the task-centric MoonBit AI task runtime and Flow-local `.ai/tasks` pack. - [`ai-task-fast-path-guide.md`](ai-task-fast-path-guide.md): end-to-end setup and tuning guide for lowest-latency AI task execution (`ai-taskd` + `fai`). - [`moonbit-rust-boundary-refactor-plan.md`](moonbit-rust-boundary-refactor-plan.md): deep scan findings, benchmark gates, and zero-cost MoonBit <> Rust boundary roadmap. - [`bench/moonbit-rust-ffi-boundary.md`](bench/moonbit-rust-ffi-boundary.md): authoritative microbenchmark for MoonBit <-> Rust FFI overhead, plus tuning guidance. - [`rl-for-myflow-harbor.md`](rl-for-myflow-harbor.md): practical RL roadmap for turning myflow -> Harbor exports into a gated training/improvement loop. - [`rl-myflow-harbor-task-specs.md`](rl-myflow-harbor-task-specs.md): executable task contracts for validate/reward/canary/hardcase stages. - [`private-fork-flow.md`](private-fork-flow.md): generic AI runbook for private-fork setup and safe push routing via Flow `[git].remote`. - [`private-repo-fast.md`](private-repo-fast.md): fastest way to create a private GitHub repo from the current folder, including non-`~/repos` paths like `~/code/flow-extension` and separate `private` mirror remotes for existing public repos. - [`commands/jj.md`](commands/jj.md): Flow wrapper for `jj` workflows (`sync`, bookmarks, and workspace/lane management). - [`jj-workspaces-for-parallel-work.md`](jj-workspaces-for-parallel-work.md): practical parallel-branch workflow with isolated workspace lanes. - [`jj-review-workspaces.md`](jj-review-workspaces.md): stable JJ-native review workspaces for branch-specific inspection and edits without touching the current checkout. - [`jj-home-branch-workflow.md`](jj-home-branch-workflow.md): status-first workflow for a long-lived home branch with stacked review or codex branches in isolated JJ workspaces. - [`moving-repos.md`](moving-repos.md): how Flow manages repo locations, `f migrate` for moving projects with AI session continuity, and the `~/code` / `~/repos` / `~/run` layout. - [`run-repos.md`](run-repos.md): shortcuts and resolution rules for running tasks in `~/run` and `~/run/i` from anywhere. - [`ai-run-task-fast-path.md`](ai-run-task-fast-path.md): minimal prompt/response contract for AI to add public or internal run tasks quickly and return ready-to-run `f ...` commands. - [`dependency-vendoring.md`](dependency-vendoring.md): inhouse dependency workflow, offender ranking, and fast upstream sync loop for vendored crates. - [`vendor-code-intelligence.md`](vendor-code-intelligence.md): opensrc-style local code indexing/search for vendored crates + first-party code via Typesense. - [`vendor-nix-inspiration.md`](vendor-nix-inspiration.md): how Flow vendoring borrows Nix ideas (pinning, reproducibility, provenance, rollback) while staying Cargo-first. - [`vendor-optimization-loop.md`](vendor-optimization-loop.md): rough-edge audit + offender ranking + iteration benchmarks for dependency optimization. ================================================ FILE: docs/install-script-latest-release-verification.md ================================================ # Verify `curl -fsSL https://myflow.sh/install.sh | sh` Installs the Latest Flow Release Use this runbook whenever you need to prove that the public installer is actually pulling the current latest stable Flow release. The fastest repo-local check is now: ```sh ./scripts/verify-install-latest-release.sh ``` Or through Flow: ```sh f verify-install-latest-release ``` This is the check that matters for users: ```sh curl -fsSL https://myflow.sh/install.sh | sh ``` ## What This Must Prove After a stable release, these values must all agree: - `Cargo.toml` package version - pushed release tag `vX.Y.Z` - GitHub `releases/latest` tag - version reported by a fresh temp-home install of `~/.flow/bin/f` If any one of those differs, the public install story is broken. ## One-Command Verification The script performs all of these checks: 1. validate expected tag vs `Cargo.toml` via `scripts/check_release_tag_version.py` 2. poll GitHub `releases/latest` until it matches the expected tag 3. run the real public installer in a fresh temp `HOME` 4. verify the installed binary version 5. download the direct release asset for the current platform and verify that too Default usage: ```sh ./scripts/verify-install-latest-release.sh ``` Useful options: ```sh ./scripts/verify-install-latest-release.sh --latest-timeout 300 ./scripts/verify-install-latest-release.sh --tag v0.1.3 ./scripts/verify-install-latest-release.sh --skip-asset ./scripts/verify-install-latest-release.sh --keep-temp ``` ## When To Run This Run this after: - cutting a new stable release tag - changing `install.sh` - changing release packaging - changing versioning logic - fixing a release mismatch bug ## Fast Pass Criteria The installer is correct only if all of these are true: 1. the release tag matches `Cargo.toml` 2. GitHub marks that tag as latest stable 3. a fresh temp-home install gets that same version 4. a direct release asset download reports that same version ## Manual Debug Procedure If the one-command script fails, use the manual steps below to see exactly where the mismatch is. ### Step 1: Confirm the Expected Version Locally Read the package version from the repo: ```sh python3 - <<'PY' import pathlib, re text = pathlib.Path("Cargo.toml").read_text(encoding="utf-8") match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) if not match: raise SystemExit("failed to read Cargo.toml version") print(match.group(1)) PY ``` If you already know the expected tag, validate it directly: ```sh python3 scripts/check_release_tag_version.py v0.1.3 ``` That script should fail hard on mismatches. ### Step 2: Confirm GitHub Latest Stable Check the public API that the installer uses: ```sh curl -fsSL https://api.github.com/repos/nikivdev/flow/releases/latest \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"])' ``` This should print the expected stable tag, for example: ```text v0.1.3 ``` Optional cross-checks: ```sh gh release list --limit 5 gh release view v0.1.3 --json tagName,publishedAt,isDraft,isPrerelease,url ``` ### Step 3: Run the Real Public Installer in a Fresh Temp HOME This is the main test. Use a fresh `HOME` and a minimal `PATH` so an existing install cannot leak in. ```sh tmp_home="$(mktemp -d)" echo "$tmp_home" HOME="$tmp_home" PATH="/usr/bin:/bin:/usr/sbin:/sbin" sh -c \ 'curl -fsSL https://myflow.sh/install.sh | sh' HOME="$tmp_home" "$tmp_home/.flow/bin/f" --version ``` Expected result: - install succeeds - `~/.flow/bin/f` exists under the temp home - `f --version` reports the latest stable version Example expected output: ```text flow 0.1.3 ``` ### Step 4: Compare Installed Version to Latest Tag Use this one-shot comparison: ```sh latest_tag="$(curl -fsSL https://api.github.com/repos/nikivdev/flow/releases/latest \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"])')" tmp_home="$(mktemp -d)" HOME="$tmp_home" PATH="/usr/bin:/bin:/usr/sbin:/sbin" sh -c \ 'curl -fsSL https://myflow.sh/install.sh | sh >/dev/null' installed_version="$(HOME="$tmp_home" "$tmp_home/.flow/bin/f" --version \ | python3 -c 'import sys,re; m=re.search(r"flow ([0-9][^ ]*)", sys.stdin.read()); print(m.group(1) if m else "")')" echo "latest_tag=$latest_tag" echo "installed_version=$installed_version" test "v$installed_version" = "$latest_tag" ``` If that final `test` fails, the installer path is not trustworthy. ### Step 5: Isolate Installer Bug vs Release Artifact Bug If the fresh install reports the wrong version, download the release asset directly. Choose the target for your machine: - macOS Apple Silicon: `aarch64-apple-darwin` - macOS Intel: `x86_64-apple-darwin` - Linux x64: `x86_64-unknown-linux-gnu` - Linux arm64: `aarch64-unknown-linux-gnu` Example for macOS Apple Silicon: ```sh latest_tag="$(curl -fsSL https://api.github.com/repos/nikivdev/flow/releases/latest \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"])')" tmp_dir="$(mktemp -d)" cd "$tmp_dir" curl -fsSLO \ "https://github.com/nikivdev/flow/releases/download/${latest_tag}/flow-aarch64-apple-darwin.tar.gz" tar -xzf flow-aarch64-apple-darwin.tar.gz ./f --version ``` Interpretation: - direct asset wrong too: release artifact/versioning bug - direct asset correct but installer wrong: installer selection logic bug - API still shows old tag: release publication/propagation is not complete yet ## Common Failure Modes ### 1. Release tag does not match Cargo version Symptom: - `python3 scripts/check_release_tag_version.py vX.Y.Z` fails Meaning: - the release was tagged from the wrong crate version Fix: - bump `Cargo.toml` - refresh generated artifacts if needed - cut a new release tag ### 2. GitHub `releases/latest` still returns the old tag Symptom: - release workflow is green - release page shows the new version - `releases/latest` still returns the old tag for a short time Meaning: - GitHub publication or cache propagation delay Fix: - wait and retry until `releases/latest` flips - do not declare success until the API itself returns the new tag ### 3. Installer reports old version but latest tag is correct Symptom: - `releases/latest` returns the new tag - temp-home install still reports an older version Meaning: - likely wrong binary inside the published release asset Fix: - test the direct asset - if direct asset is also wrong, cut a corrected release ### 4. Temp-home test passes locally but users still get old `f` Symptom: - your test is clean - user machine still reports an older version by plain `f --version` Meaning: - user shell is resolving another binary earlier on `PATH` Fix: - ask them to run: ```sh which -a f ~/.flow/bin/f --version ``` ## Recommended Release Checklist After publishing a stable tag: 1. run `python3 scripts/check_release_tag_version.py vX.Y.Z` 2. wait for the Release workflow to complete 3. verify `releases/latest` returns `vX.Y.Z` 4. run `./scripts/verify-install-latest-release.sh` 5. if there is any mismatch, test the direct asset before debugging the installer Do not mark the release done until step 4 is green. ================================================ FILE: docs/jj-home-branch-workflow.md ================================================ # JJ Home-Branch Workflow This workflow is for teams or individuals who keep one long-lived personal branch on top of trunk, then stack short-lived task branches on top of that branch. Flow supports that model directly through: - `f status` for a workflow-aware status view - `jj.home_branch` in `flow.toml` - `f jj workspace review <branch>` for isolated branch-specific working copies ## Mental model Use three layers: 1. trunk: `main` 2. home branch: your long-lived integration branch 3. leaf branches: `review/*`, `codex/*`, or other task branches that sit on top of the home branch The default checkout usually stays on the home branch. Branch-specific work happens in isolated JJ workspaces. ## Config ```toml [jj] default_branch = "main" home_branch = "alice" ``` ## Status as the preflight Before switching branches, creating workspaces, committing, or publishing, run: ```bash f status ``` This should tell you: - current workspace and path - current branch and its role - configured home branch - whether you are on the home branch or a leaf branch - which `review/*` and `codex/*` branches currently sit on top of the home branch - whether the working copy is clean enough to mutate safely Use raw `f jj status` only when you need the underlying Jujutsu status output. ## Default operating pattern Keep the main checkout on the home branch: ```bash cd ~/code/org/project f status ``` Create or reuse an isolated workspace for branch-specific work: ```bash f jj workspace review review/alice-feature cd ~/.jj/workspaces/project/review-alice-feature f status ``` Inside the workspace, use `jj` or `f jj`. ## Why this is safer - The main checkout stays stable. - Branch-specific edits do not mix with unrelated home-branch changes. - A second Codex or Claude session can work in the review workspace without disturbing the main checkout. - `f status` provides one consistent summary instead of forcing the user or agent to infer state from several lower-level commands. ## Publish boundary The important distinction is not just the branch name. It is also where the work lives: - Git branch checkout - JJ review workspace Be explicit about the publish path. Do not assume a colocated Git checkout and a JJ workspace are interchangeable. ## Recommended rule set - default checkout: home branch - task work: review workspace - preflight before mutation: `f status` - inspect raw JJ details only when needed: `f jj status` That keeps the workflow legible to both humans and coding agents. ================================================ FILE: docs/jj-review-workspaces.md ================================================ # JJ Review Workspaces When you need an isolated working copy for a review branch, use a **JJ review workspace** instead of switching your current checkout or creating an ad hoc temporary worktree. This is the safest way to inspect, edit, and validate a review branch while leaving your current repo state untouched. ## Command ```bash cd ~/code/org/project f status f jj workspace review review/alice-feature ``` This creates or reuses a stable workspace at: ```bash ~/.jj/workspaces/project/review-alice-feature ``` Then work there: ```bash cd ~/.jj/workspaces/project/review-alice-feature f status ``` ## Why Use This - Your current checkout stays exactly as it is. - The workspace path is stable and reusable. - Another Codex or Claude session can work inside the review workspace safely. - You avoid mixing “temporary scratch path” decisions into your review flow. - `f status` makes the home-branch versus review-workspace role explicit before you mutate anything. ## How It Resolves The Base `f jj workspace review <branch>` chooses the workspace base in this order: 1. `--base <rev>` if you passed one 2. the local Git branch commit for `<branch>` 3. the remote Git branch commit for `<remote>/<branch>` 4. trunk (`<default_branch>` or `<default_branch>@<remote>`) That makes the command useful both before and after the review branch exists locally. ## Reuse Behavior If the review workspace already exists, Flow reuses it instead of creating another copy. That means this is safe to run repeatedly: ```bash f jj workspace review review/alice-feature ``` You get one stable place for that branch, not a pile of temporary directories. ## Important Caveat In Colocated Repos Use `jj` or `f jj` inside the review workspace. In a colocated repo, plain `git` still points at the main Git checkout, not the JJ workspace's working-copy commit. Because of that, `f jj workspace review` intentionally does **not** try to run branch-switching logic for you. Recommended rule: - inside the review workspace: use `jj` / `f jj` - in the main checkout: use your normal Git or Flow branch-switch flow ## Example Workflow ```bash # Create or reuse the review workspace f jj workspace review review/alice-feature # Move into it cd ~/.jj/workspaces/project/review-alice-feature # Inspect state jj st jj log -r @ # Make edits and commit as usual jj describe -m "Adjust runtime startup behavior" jj new ``` If you want a tracked bookmark for publishing later: ```bash f jj bookmark create review/alice-feature --rev @ --track --remote origin ``` ## Cleanup When you no longer need the workspace: ```bash jj workspace list jj workspace forget review-alice-feature rm -rf ~/.jj/workspaces/project/review-alice-feature ``` ## When To Use This vs `lane` Use `f jj workspace lane <name>` when you want a new parallel line of work anchored from trunk. Use `f jj workspace review <branch>` when the workspace should correspond to a specific review branch and keep a stable branch-derived path. ================================================ FILE: docs/jj-workspaces-for-parallel-work.md ================================================ # JJ Workspaces: Work on Multiple Branches Simultaneously When you need to reference or work with code from another branch without disrupting your current work, use **jj workspaces**. This creates a second working copy of the same repo at a different path, pointed at a different revision. ## The Problem You're on `feature-a` actively coding. You need to send another Claude session a prompt like "study the tracing code on `pr/main-fdb3446`" — but you can't check out that branch without losing your current working state. ## Solution: `jj workspace add` ```bash cd ~/code/org/project jj workspace add ../project-traces -r pr/main-fdb3446 ``` Flow wrapper (recommended): ```bash cd ~/code/org/project f jj workspace lane traces --base pr/main-fdb3446 --path ../project-traces ``` Now you have two working copies sharing the same repo: | Path | Branch | Use | |------|--------|-----| | `~/code/org/project` | `feature-a` | Your active work (untouched) | | `~/code/org/project-traces` | `pr/main-fdb3446` | Full checkout for reference | Point any tool or Claude session at the second path — it has all files on disk, no risk to your branch. ## Common Workflows ### Reference code from another branch ```bash # Create workspace jj workspace add ../project-ref -r some-branch # Now another Claude session can freely explore: # "study ~/code/org/project-ref — it has the tracing code" # Clean up when done jj workspace forget project-ref && rm -rf ../project-ref ``` ### Cherry-pick files across branches ```bash # No workspace needed — jj reads any revision directly: jj file show src/lib/tracing.ts -r pr/main-fdb3446 jj diff --from main --to pr/main-fdb3446 --stat ``` ### Work on two PRs at once ```bash f jj workspace lane pr2 --base pr/feature-b --path ../project-pr2 # Edit files in both directories independently # Both share the same jj repo — commits are visible everywhere ``` ### Reuse one stable workspace for a review branch ```bash f jj workspace review review/alice-feature cd ~/.jj/workspaces/project/review-alice-feature ``` Use this when you want one predictable workspace path for a specific review branch instead of a general-purpose lane. ### Default isolated lanes from trunk ```bash f jj workspace lane fix-otp f jj workspace lane release-testflight ``` By default this fetches and anchors each lane on `<default_branch>@<remote>` (or `<default_branch>` if the remote bookmark is missing). ## How It Works - Both workspaces share the same `.jj/` repo backend (no git clone, no duplication) - Each workspace has its own working copy commit (`@`) - Changes committed in one workspace are immediately visible in the other via `jj log` - The original workspace is completely unaffected ## Cleanup ```bash # List workspaces jj workspace list # Remove a workspace (keeps the commits, removes the directory association) jj workspace forget <name> rm -rf ../project-ref ``` ## When to Use What | Need | Tool | |------|------| | Full directory for another tool/session to explore | `jj workspace add` | | Stable branch-specific review workspace | `f jj workspace review <branch>` | | Read a specific file from another branch | `jj file show <path> -r <rev>` | | See what changed on another branch | `jj diff --from main --to <branch>` | | Compare two branches | `jj log -r 'branchA..branchB'` | ================================================ FILE: docs/local-domains-domainsd-cpp-spec.md ================================================ # Local Domains Native Daemon (C++) Spec This document defines the native local-domains path for Flow. Goal: - keep stable `*.localhost` names, - remove docker/nginx runtime dependency, - provide low-overhead local routing suitable for agent-heavy workflows. ## Scope This is an incremental migration path. Phase 1 (implemented now): - opt-in engine: `f domains --engine native ...` - experimental C++ daemon (`domainsd-cpp`) built by Flow with `clang++` - host-based HTTP/1.1 routing from `~/.config/flow/local-domains/routes.json` - WebSocket upgrade passthrough - request-side chunked transfer-encoding decode - upstream keepalive connection pooling for safe HTTP/1.1 reuse - bounded active handler slots with overload shedding (`503`) - upstream connect/read/write timeouts (`504` for upstream connect timeout) - health endpoint: `/_flow/domains/health` - macOS launchd socket-activation installer for native privileged `:80` bind without Docker Phase 2: - better connection pooling and backpressure controls - optional HTTP/2/TLS frontend mode Phase 3: - optional HTTPS + HTTP/2 - structured trace export for agent context ## Non-goals (for current phase) - system DNS changes - packet filter / firewall manipulation - replacing `.localhost` conventions ## Control plane Flow CLI remains the control plane: ```bash f domains list f domains add app.localhost 127.0.0.1:3000 f domains rm app.localhost f domains --engine native up f domains --engine native down f domains --engine native doctor ``` Engine selection: - CLI flag: `--engine docker|native` - env fallback: `FLOW_DOMAINS_ENGINE=native` - default: `docker` ## State Current state remains: - routes: `~/.config/flow/local-domains/routes.json` Native runtime artifacts: - pid: `~/.config/flow/local-domains/domainsd.pid` - log: `~/.config/flow/local-domains/domainsd.log` - built daemon binary: `~/.config/flow/local-domains/domainsd-cpp` - macOS launchd plist (optional): `/Library/LaunchDaemons/dev.flow.domainsd.plist` Native tuning env vars (read by `f domains --engine native up` and passed to daemon): - `FLOW_DOMAINS_NATIVE_MAX_ACTIVE_CLIENTS` (default `128`) - `FLOW_DOMAINS_NATIVE_UPSTREAM_CONNECT_TIMEOUT_MS` (default `10000`) - `FLOW_DOMAINS_NATIVE_UPSTREAM_IO_TIMEOUT_MS` (default `15000`) - `FLOW_DOMAINS_NATIVE_CLIENT_IO_TIMEOUT_MS` (default `30000`) - `FLOW_DOMAINS_NATIVE_POOL_MAX_IDLE_PER_KEY` (default `8`) - `FLOW_DOMAINS_NATIVE_POOL_MAX_IDLE_TOTAL` (default `256`) - `FLOW_DOMAINS_NATIVE_POOL_IDLE_TIMEOUT_MS` (default `15000`) - `FLOW_DOMAINS_NATIVE_POOL_MAX_AGE_MS` (default `120000`) ## Native daemon protocol (current) Listen address: - `127.0.0.1:80` macOS launchd mode: - daemon can be started with `--launchd-socket domainsd` (socket inherited from launchd) - launchd owns privileged bind; daemon runs as local user Routing: - Read `Host` header (strip `:port`) - Lookup `host -> target` from `routes.json` - Forward request to target (`host:port`) Special endpoint: - `GET /_flow/domains/health` - returns header `X-Flow-Domainsd: 1` Errors: - `404` when host route is not configured - `502` on upstream connection/forward failures - `503` when proxy is saturated and sheds overload - `504` on upstream connect timeout - `400` for malformed HTTP requests ## Reliability guardrails - Keep Docker engine as default during hardening. - Native engine is explicit opt-in. - `f domains doctor` should always show effective owner on port 80. - Any startup failure must surface log path directly. ## Performance targets - added local proxy latency p99 < 1ms for tiny responses - idle CPU near zero - route update visibility < 100ms (mtime-based reload) ## Validation checklist ```bash f domains add myflow.localhost 127.0.0.1:3000 sudo ./tools/domainsd-cpp/install-macos-launchd.sh # macOS when direct bind is denied f domains --engine native up f domains --engine native doctor curl -H 'Host: myflow.localhost' http://127.0.0.1/ sudo ./tools/domainsd-cpp/uninstall-macos-launchd.sh # optional teardown ``` ## Next implementation work 1. Add per-upstream timeouts (connect, first-byte, total) with explicit 502/504 mapping. 2. Add end-to-end trace summary output for agents (route misses, upstream failures, latency buckets). 3. Add launchd install/uninstall tasks for persistent local startup. 4. Add optional HTTPS + HTTP/2 frontend mode. ================================================ FILE: docs/local-domains-no-random-ports.md ================================================ # Local Domains, No Random Ports This pattern gives stable local URLs like `http://gen.localhost` and `http://linsa.localhost` instead of remembering random ports. Use shared ownership via `f domains` (see `docs/commands/domains.md`) so only one proxy binds port `80` across all repos. It is fast and lightweight: - One shared local reverse proxy container (nginx). - No system-wide DNS daemon required. - No VPN or packet filter changes. Experimental native path: - `f domains --engine native up` runs a local C++ daemon instead of docker/nginx. - Keep this opt-in for now; docker remains the default engine. ## Why `.localhost` Use `*.localhost` hostnames. They resolve to loopback by design, so traffic stays on your machine. That means: - `gen.localhost` can map to `127.0.0.1:5001`. - `linsa.localhost` can map to `127.0.0.1:3481`. - `api.myflow.localhost` can map to `127.0.0.1:8780`. ## Recommended Pattern: Shared `f domains` Register routes once, then run your normal dev servers on fixed ports. ```bash f domains add gen.localhost 127.0.0.1:5001 f domains add linsa.localhost 127.0.0.1:3481 f domains add myflow.localhost 127.0.0.1:3000 f domains add api.myflow.localhost 127.0.0.1:8780 f domains up f domains list ``` `f domains up` ensures the shared proxy is running. `f domains list` shows the active route table. Native engine (experimental): ```bash f domains --engine native up f domains --engine native doctor f domains --engine native down ``` On macOS, if native bind to `:80` is denied, install launchd socket mode once: ```bash cd ~/code/flow sudo ./tools/domainsd-cpp/install-macos-launchd.sh ``` You can also set: ```bash export FLOW_DOMAINS_ENGINE=native ``` ## Flow Task Pattern (`flow.toml`) Use these task shapes in each repo: ```toml [[tasks]] name = "domains-up" command = """ set -euo pipefail f domains add <repo>.localhost 127.0.0.1:<port> f domains up """ [[tasks]] name = "domains-down" command = "sh -lc 'f domains rm <repo>.localhost || true'" [[tasks]] name = "domains-status" command = "f domains doctor && f domains list" ``` Example mappings used together safely: ```bash gen.localhost -> 127.0.0.1:5001 linsa.localhost -> 127.0.0.1:3481 myflow.localhost -> 127.0.0.1:3000 api.myflow.localhost -> 127.0.0.1:8780 ``` ## Reliability Notes - Keep app ports fixed (`5001`, `3481`, `3000`, `8780`) and route hostnames to them. - Do not run per-repo proxy stacks in parallel with `f domains`; use one shared proxy owner. - Check health with: ```bash f domains doctor ``` ## Troubleshooting - `ERR_CONNECTION_REFUSED` on `*.localhost`: run `f domains up`, then `f domains doctor`. - Wrong project opens on a hostname: route collision or stale mapping. Check `f domains list`, then `f domains rm <host>` and re-add. - Port `80` bind failure: another process owns port `80`. Find it with: ```bash lsof -nP -iTCP:80 -sTCP:LISTEN ``` ## Logs in myflow If you use `myflow` as your local operations UI, open: - `http://myflow.localhost/processes` for process status and per-process log streams. - `http://myflow.localhost/logs` for focused live logs. Run `f lin` first so the Flow daemon is online for these pages. For the full end-to-end setup (`f domains --engine native`, `f dev`, health checks, and troubleshooting), see: `docs/myflow-localhost-runbook.md`. ## Legacy Pattern (Not Recommended) Per-repo docker-compose proxies also work, but they are easier to conflict on port `80` and cause hostname drift across repos. Prefer shared `f domains` unless you have a strict repo-isolated requirement. ## Result You keep internal service ports explicit in config, but humans use stable names: - `http://gen.localhost` - `http://linsa.localhost` - `http://myflow.localhost` ================================================ FILE: docs/log-ingesting.md ================================================ # Log Ingestion Flow includes a log ingestion system for collecting and querying structured logs from your projects. Logs are stored in SQLite for later analysis. ## Starting the Server ```bash f server ``` This starts the HTTP server on `127.0.0.1:9060` (default). Options: - `--host <IP>` - Bind address (default: 127.0.0.1) - `--port <PORT>` - Port number (default: 9060) ## Endpoints ### Health Check ``` GET /health ``` Returns `{"status": "ok"}` when the server is running. ### Ingest Logs ``` POST /logs/ingest Content-Type: application/json ``` **Single log:** ```json { "project": "my-app", "content": "TypeError: Cannot read property 'x' of undefined", "timestamp": 1733150000000, "type": "error", "service": "api", "stack": "at handler (api.ts:42)\nat processRequest (server.ts:100)", "format": "text" } ``` **Batch:** ```json [ { "project": "my-app", "content": "Request received", "timestamp": 1733150000000, "type": "log", "service": "api", "format": "text" }, { "project": "my-app", "content": "Database query", "timestamp": 1733150001000, "type": "log", "service": "db", "format": "text" } ] ``` **Response:** ```json { "inserted": 1, "ids": [42] } ``` ### Query Logs ``` GET /logs/query ``` **Query parameters:** | Parameter | Description | | --------- | -------------------------------------- | | `project` | Filter by project name | | `service` | Filter by service | | `type` | Filter by log type (`log` or `error`) | | `since` | Timestamp (ms) - logs after this time | | `until` | Timestamp (ms) - logs before this time | | `limit` | Max results (default: 100) | | `offset` | Skip N results for pagination | **Examples:** ```bash # All logs curl "http://127.0.0.1:9060/logs/query" # Errors for a project curl "http://127.0.0.1:9060/logs/query?project=my-app&type=error" # Logs from the last hour curl "http://127.0.0.1:9060/logs/query?since=$(($(date +%s) * 1000 - 3600000))" ``` ## Log Entry Schema | Field | Type | Required | Description | | ----------- | ------- | -------- | --------------------------------------- | | `project` | string | yes | Project identifier | | `content` | string | yes | Log message or error text | | `timestamp` | integer | yes | Unix timestamp in milliseconds | | `type` | string | yes | `"log"` or `"error"` | | `service` | string | yes | Service/task name that produced the log | | `stack` | string | no | Stack trace for errors | | `format` | string | no | `"text"` (default) or `"json"` | ## Database Logs are stored in `~/.config/flow/flow.db` in the `logs` table. You can query directly: ```bash sqlite3 ~/.config/flow/flow.db "SELECT * FROM logs WHERE log_type='error' ORDER BY timestamp DESC LIMIT 10;" ``` ## Client Examples ### TypeScript/JavaScript ```typescript async function sendLog(entry: { project: string; content: string; type: "log" | "error"; service: string; stack?: string; }) { await fetch("http://127.0.0.1:9060/logs/ingest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...entry, timestamp: Date.now(), format: "text", }), }); } // Usage sendLog({ project: "my-app", content: "User login failed", type: "error", service: "auth", }); ``` ### Python ```python import requests import time def send_log(project, content, log_type, service, stack=None): requests.post("http://127.0.0.1:9060/logs/ingest", json={ "project": project, "content": content, "timestamp": int(time.time() * 1000), "type": log_type, "service": service, "stack": stack, "format": "text" }) # Usage send_log("my-app", "Database connection failed", "error", "db") ``` ### curl ```bash curl -X POST http://127.0.0.1:9060/logs/ingest \ -H "Content-Type: application/json" \ -d '{"project":"my-app","content":"Test error","timestamp":'$(date +%s000)',"type":"error","service":"cli","format":"text"}' ``` ## Testing Run the test task to verify the system is working: ```bash # Terminal 1: Start the server f server # Terminal 2: Run tests f test-log-server ``` ================================================ FILE: docs/moonbit-ai-tasks-implementation.md ================================================ # MoonBit AI Tasks: Implementation Inventory This document captures the task-centric MoonBit implementation added in Flow, where `.ai/tasks/*.mbt` is the primary extension mechanism. ## Scope The implementation adds: - discovery and execution of AI MoonBit tasks under `.ai/tasks/` - CLI/docs updates to make `tasks` the primary interface - legacy `recipe` command demoted to compatibility mode - a concrete task pack for Flow self-development ## Code Paths Added/Changed Core runtime and wiring: - `src/ai_tasks.rs` - `src/ai_taskd.rs` - `src/tasks.rs` - `src/bin/ai_taskd_client.rs` - `src/palette.rs` - `src/cli.rs` - `src/lib.rs` Legacy compatibility updates: - `src/recipe.rs` Docs updates: - `docs/commands/tasks.md` - `docs/commands/recipe.md` - `docs/commands/readme.md` - `docs/index.mdx` - `docs/moonbit-ai-tasks-implementation.md` Workspace hygiene: - `.gitignore` (ignore Moon generated dirs under `.ai/tasks/**`) ## Runtime Behavior ### Discovery Flow scans `.ai/tasks/` recursively for `.mbt` files and exposes selectors as `ai:<path>`. Key behavior in `src/ai_tasks.rs`: - ignores generated Moon artifacts during discovery (`.mooncakes`, `_build`) - parses metadata from top comments: - `// title: ...` - `// description: ...` - `// tags: [a, b]` - resolves stable task IDs and selectors from path layout ### Selection Task resolution accepts: - full selector: `ai:flow/dev-check` - scoped selector forms - name-based matching with ambiguity detection ### Execution Flow executes AI tasks through a cache-first runtime. Important execution details: - auto-resolves nearest Moon workspace root (`moon.mod.json` / `moon.mod`) - computes content hash (task source + Moon config + moon version) - builds native artifact once (`moon build --target native --release`) - reuses cached binary from `~/Library/Caches/flow/ai-tasks/<hash>/task-bin` - falls back to `moon run` for tasks without Moon workspace metadata - optional mode control via `FLOW_AI_TASK_MODE` (`dev`, `release`, `js`, etc.) - default frozen dependency behavior unless `FLOW_AI_TASK_NO_FROZEN` is set - runtime override via `FLOW_AI_TASK_RUNTIME=moon-run` ### Daemon Flow now includes a lightweight Unix-socket daemon for repeated AI task runs: - socket: `~/.flow/run/ai-taskd.sock` - lifecycle: `f tasks daemon start|status|stop` - daemon execution: `f tasks run-ai --daemon <selector>` - low-overhead client execution: `./target/release/ai-taskd-client <selector>` Recent runtime optimizations: - daemon-side task discovery cache with TTL (`FLOW_AI_TASKD_DISCOVERY_TTL_MS`) - daemon-side hot artifact reuse cache with TTL (`FLOW_AI_TASKD_ARTIFACT_TTL_MS`) - fast exact selector resolution (skip full `.ai/tasks` scan for `ai:scope/task`) - cache key generation optimized to use file metadata fingerprints + cached Moon version - optional low-latency dispatch via `fai` with auto-preference from `f` for latency-tagged AI tasks in daemon mode ## Task Pack Added Flow-local task pack under `.ai/tasks/flow/`: - `.ai/tasks/flow/dev-check/main.mbt` - `.ai/tasks/flow/pr-ready/main.mbt` - `.ai/tasks/flow/regression-smoke/main.mbt` - `.ai/tasks/flow/release-preflight/main.mbt` - `.ai/tasks/flow/bench-cli/main.mbt` - `.ai/tasks/flow/noop/main.mbt` Each task has its own Moon package/workspace files: - `.ai/tasks/flow/<task>/moon.mod.json` - `.ai/tasks/flow/<task>/moon.pkg.json` ### Task Intents - `ai:flow/dev-check`: fast quality gate (`cargo check`, targeted tests, CLI help smoke) - `ai:flow/pr-ready`: pre-PR gate (dev-check + docs parity + gitignore hygiene) - `ai:flow/regression-smoke`: temporary project smoke for task discovery/execution - `ai:flow/release-preflight`: build release binary and run release-path smoke checks - `ai:flow/bench-cli`: quick latency benchmark for high-frequency Flow CLI entry points ## How To Run From `~/code/flow`: ```bash f tasks list f tasks build-ai ai:flow/dev-check f tasks run-ai ai:flow/dev-check f tasks run-ai --daemon ai:flow/dev-check f tasks daemon start f tasks daemon status f tasks daemon stop f ai:flow/dev-check f ai:flow/pr-ready f ai:flow/regression-smoke f ai:flow/release-preflight f ai:flow/bench-cli ``` Optional benchmark controls: ```bash FLOW_BENCH_ITERATIONS=30 FLOW_BENCH_WARMUP=5 f ai:flow/bench-cli ``` Runtime-path benchmark harness: ```bash f bench-ai-runtime --iterations 80 --warmup 10 --json-out /tmp/flow_ai_runtime_bench.json ``` ## Automated myflow Commit→Session Check You can automate the exact flow: 1. Do real Claude/Codex work in a repo (for example `~/code/myflow`). 2. Commit with sync (`f commit --sync ...`). 3. Verify that commit is visible in myflow and has attached sessions. Flow task: ```bash f myflow-commit-session-smoke --help ``` Common run for `~/code/myflow`: ```bash f myflow-commit-session-smoke --repo-path ~/code/myflow --require-sessions ``` What it checks: - `GET /api/commits?repo=<owner>/<repo>` contains the target commit - commit has `sessionWindow` metadata - if `--require-sessions` is set, commit has `sessions.length > 0` - first session id is fetchable via `GET /api/sessions/:id` (unless `--skip-session-fetch`) Auth: - uses `MYFLOW_TOKEN` if set - otherwise falls back to `~/.config/flow/auth.toml` token ## Validation Commands ```bash cargo check --all-targets cargo build --release --bin f f tasks list | rg '^ai:flow/' f ai:flow/regression-smoke f myflow-commit-session-smoke --repo-path ~/code/myflow --require-sessions ``` ## Generated Artifact Hygiene To prevent accidental commit noise from Moon caches/build output, Flow ignores: - `.ai/tasks/**/.mooncakes/` - `.ai/tasks/**/_build/` ## Notes for Commits When committing this work, scope to the relevant code + docs only: ```bash f commit --path src/ai_tasks.rs \ --path src/tasks.rs \ --path src/palette.rs \ --path src/cli.rs \ --path src/lib.rs \ --path src/recipe.rs \ --path docs/commands/tasks.md \ --path docs/commands/recipe.md \ --path docs/commands/readme.md \ --path docs/moonbit-ai-tasks-implementation.md \ "add task-centric moonbit ai task runtime and flow task pack" ``` ================================================ FILE: docs/moonbit-rust-boundary-refactor-plan.md ================================================ # MoonBit Runtime Refactor Plan (Flow) This plan is based on the current implementation in: - `src/ai_tasks.rs` - `src/ai_taskd.rs` - `src/tasks.rs` - `.ai/tasks/flow/*` Goal: keep Flow's Rust core stable while moving high-change task logic to MoonBit with near-zero boundary overhead and no performance regressions. ## 1. Current Scan: Where Refactor Pressure Exists ### 1.1 Task execution policy is still split across multiple layers Current paths: - `f ai:...` and task shortcut route through `tasks::run_with_discovery` in `src/tasks.rs`. - runtime policy (cached vs moon-run fallback) lives in `ai_tasks::run_task` in `src/ai_tasks.rs`. - daemon path (`f tasks run-ai --daemon`) is implemented separately in `src/ai_taskd.rs`. Refactor target: - Introduce one `AiTaskExecutor` policy entrypoint in Rust, used by all callsites. - Make shortcut path and explicit `tasks run-ai` path share identical behavior and telemetry. ### 1.2 Startup/daemon policy is command-level, not config-level Current state: - daemon usage is chosen by CLI flags. Refactor target: - Add config-level defaults (e.g. `[ai_tasks] mode = "cached"`, `daemon = true`) and keep CLI as override. ### 1.3 Task pack design is still shell-heavy Current state: - most `.ai/tasks/flow/*` tasks call shell commands directly. Refactor target: - move hot utility operations to typed host APIs over a stable ABI (git, file, json, process spawn, clock), reducing shell parsing/process overhead and improving deterministic latency. ## 2. Benchmark Harness (Implemented) Added: - `scripts/bench-ai-runtime.py` - `scripts/bench-moonbit-rust-ffi.py` - `flow.toml` task: `bench-ai-runtime` - `flow.toml` task: `bench-ffi-boundary` - minimal benchmark task: `.ai/tasks/flow/noop/*` - FFI microbench projects: - `bench/ffi_host_boundary` (Rust staticlib + Rust baseline bench) - `bench/moon_ffi_boundary` (MoonBit native bench calling Rust host exports) Benchmark scenarios: - `rust_help` - `moon_run_noop` - `cached_noop` - `daemon_cached_noop` - `cached_binary_direct` Run: ```bash cd ~/code/flow f bench-ai-runtime --iterations 80 --warmup 10 --json-out /tmp/flow_ai_runtime_bench.json ``` This is the baseline gate to ensure refactors do not regress p95 latency. Run boundary-only microbench: ```bash f bench-ffi-boundary --iters 10000000 --json-out /tmp/flow_ffi_boundary.json ``` ## 3. Zero-Cost Boundary Design (MoonBit <> Rust) ### 3.1 Recommended boundary model Use a narrow C ABI with primitive handles, not JSON strings, for hot paths. - Rust hosts the scheduler, caches, daemon, security, lifecycle. - MoonBit tasks compile to native and call host exports via `extern "C"`. - Data boundary uses: - integers/enums for operation IDs and status - offsets/lengths into shared byte buffers for string/bytes payloads - opaque handles for host-managed resources Why: this minimizes allocation/serialization churn and gives a predictable ABI. ### 3.2 ABI contract for hot calls Candidate host functions: - `flow_host_now_ns() -> u64` - `flow_host_log(level: u32, ptr: *const u8, len: u32) -> i32` - `flow_host_spawn(cmd_ptr, cmd_len, argv_ptr, argv_len, out_handle) -> i32` - `flow_host_read_file(path_ptr, path_len, out_handle) -> i32` - `flow_host_git(op: u32, in_handle, out_handle) -> i32` - `flow_host_drop_handle(handle: u32)` For MoonBit string interop helpers, the `justjavac/ffi` package is useful for C-string/wide-string conversions, but should remain at the edge of the ABI where text crossing is required. ### 3.3 Boundary rules for latency - No JSON over FFI in hot loops. - No per-call dynamic symbol resolution. - Keep calls idempotent and batch-friendly. - Use borrow/owned annotations carefully on MoonBit side to avoid refcount overhead bugs. - Prefer fixed buffers + explicit lengths over repeated string allocations. ## 4. Refactor Roadmap ### Phase A (now) - Keep current cached runtime + daemon. - Add benchmark gates and require p95 non-regression before merge. ### Phase B - Extract unified `AiTaskExecutor` in Rust. - Route all task entrypoints through one policy engine + one telemetry schema. ### Phase C - Add `ai_task_host` C ABI layer in Rust. - Migrate one hot operation from shell to typed host call as a benchmarked pilot. ### Phase D - Expand typed host API surface for common task operations. - Keep shell fallback for compatibility. ## 5. Regression Gates Use these checks before approving runtime changes: 1. `f bench-ai-runtime --iterations 80 --warmup 10 --json-out ...` 2. Compare p95 for: - `cached_noop` - `daemon_cached_noop` 3. Require: - no worse than +10% p95 vs baseline on same machine/load - no task failures ## 6. What "good" looks like - Rust rebuilds become rare for workflow-level changes. - Most iteration happens in `.ai/tasks/*.mbt`. - Hot-path operations cross Rust/MoonBit boundary with primitive ABI payloads and stable p95 latency. ================================================ FILE: docs/moving-repos.md ================================================ # Moving Repos with Flow How Flow manages repository locations, migration, and AI session continuity. ## Directory Layout Flow uses three managed roots: | Root | Purpose | |------|---------| | `~/code` | Active projects (`f code`) | | `~/repos` | Cloned third-party repos (`f repos`) | | `~/run` | Task-execution repos (`f r`, `f ri`, `f rp`) | ## Cloning Into the Right Place ### `f clone` Clones with git-like destination behavior from your current working directory: ```bash f clone owner/repo f clone https://github.com/owner/repo f clone owner/repo local-folder ``` GitHub inputs are normalized to SSH URLs, but destination behavior matches `git clone` (no forced `~/repos` root). ### `f repos clone` Clones GitHub repos into `~/repos/<owner>/<repo>`: ```bash f repos clone owner/repo # -> ~/repos/owner/repo f repos clone https://github.com/owner/repo ``` Shallow clone by default with background full-history fetch. Auto-sets upstream remote for forks. Initializes jj with `--colocate`. `~/repos` is immutable by default. Override with `FLOW_REPOS_ALLOW_ROOT_OVERRIDE=1`. See [commands/repos.md](commands/repos.md) for full options. ### `f code` Fuzzy-search git repos under `~/code` and open in editor: ```bash f code # fzf picker over ~/code f code list # list all repos under ~/code ``` ## Moving a Project ### `f migrate` (primary command) Moves or copies a project folder and automatically: 1. Moves/copies the directory (handles cross-device transparently) 2. Relinks `~/bin` symlinks pointing into the old path (move only) 3. Migrates Claude and Codex AI sessions to the new path Three usage forms: ```bash # Move current dir into ~/code/<relative> cd ~/old/location/myproject f migrate code myproject # -> ~/code/myproject f migrate code lang/rust/mylib # -> ~/code/lang/rust/mylib # Move current dir to any path f migrate ~/code/stream # Move a specific source to a target (no cd needed) f migrate ~/code/lang/cpp/stream ~/code/stream ``` Options: | Flag | Effect | |------|--------| | `--copy` / `-c` | Copy instead of move (keeps original) | | `--dry-run` | Preview without writing | | `--skip-claude` | Skip Claude session migration | | `--skip-codex` | Skip Codex session migration | Preview first: ```bash f migrate --dry-run code stream ``` ### What Happens to AI Sessions Claude and Codex store project sessions keyed by filesystem path: - **Claude**: `~/.claude/projects/<path-key>` directories are renamed - **Codex**: `~/.codex/projects/<path-key>` directories are renamed, plus `.jsonl` session files under `~/.codex/sessions/` are updated in-place (the `cwd` field in `session_meta` records) - **Seq zvec index**: if present at `~/repos/alibaba/zvec/data/agent_qa.jsonl`, matching docs are migrated so `metadata.project_path` (and project-keyed `metadata.source_path`) follows the new repo path for semantic session search. After migration a summary is printed: ``` Session migration summary: Claude project dirs moved: 1 Codex legacy dirs moved: 1 Codex jsonl files updated: 2 Seq zvec docs updated: 124 ``` When copying (`--copy`), sessions are duplicated with a derived ID so both locations have independent history. Seq zvec docs are duplicated too, with copied doc IDs and rewritten `metadata.project_path`. ### `f code migrate` (alternative form) Same as `f migrate code` but accessed through the `code` subcommand: ```bash f code migrate ~/old/path myproject # -> ~/code/myproject ``` ### `f code move-sessions` (standalone session migration) Migrate only AI sessions without moving any files: ```bash f code move-sessions --from /old/path --to /new/path f code move-sessions --from /old/path --to /new/path --dry-run ``` Useful when you moved a repo manually and need to fix sessions after the fact. This also updates Seq zvec path metadata when the index exists. To override the default zvec file or disable zvec migration: ```bash # Use a custom zvec JSONL file export FLOW_AGENT_QA_ZVEC_JSONL=/path/to/agent_qa.jsonl # Disable zvec migration for this command export FLOW_AGENT_QA_ZVEC_JSONL="" ``` ## Run Repos (`~/run`) Run repos are a separate system for executing Flow tasks across multiple codebases without `cd`. ```bash f r <task> # run in ~/run f ri <task> # run in ~/run/i f rp <project> <task> # run in ~/run/<project> (falls back to i/<project>) f rip <project> <task> # run in ~/run/i/<project> ``` Management: ```bash f run-load <name> <url> # clone/update a run repo f run-sync # sync all run repos f run-list # list all run repos ``` See [run-repos.md](run-repos.md) for full details. ## Common Workflows ### Move a project into `~/code` ```bash cd ~/downloads/cool-project f migrate code cool-project # -> ~/code/cool-project with sessions migrated ``` ### Reorganize nested projects ```bash f migrate ~/code/lang/cpp/stream ~/code/stream # Directory moved, ~/bin symlinks updated, sessions migrated ``` ### Clone a fork with upstream tracking ```bash f repos clone myfork/repo # -> ~/repos/myfork/repo # upstream auto-detected via gh API, jj initialized ``` ### Copy a project for experimentation ```bash f migrate --copy ~/code/app ~/code/app-experiment # Original untouched, sessions duplicated with new IDs ``` ### Fix sessions after a manual move ```bash mv ~/code/old ~/code/new f code move-sessions --from ~/code/old --to ~/code/new ``` ## See Also - [commands/clone.md](commands/clone.md) — `f clone` (git-like destination behavior) - [commands/repos.md](commands/repos.md) — `f repos clone` / `f repos create` - [commands/migrate.md](commands/migrate.md) — `f migrate` full reference - [run-repos.md](run-repos.md) — run repo shortcuts ================================================ FILE: docs/myflow-localhost-runbook.md ================================================ # myflow.localhost Runbook (Native Domains) This is the concrete setup for running `~/code/myflow` with stable local domains: - web UI: `http://myflow.localhost` - optional API hostname: `http://api.myflow.localhost` No random ports to remember in daily browser use. ## Prereqs - Flow CLI available as `f` - `clang++` installed (for native domains daemon build) - myflow repo at `~/code/myflow` If your mac blocks native bind to port `80`, install launchd socket mode once: ```bash cd ~/code/flow sudo ./tools/domainsd-cpp/install-macos-launchd.sh ``` ## One-time route setup ```bash f domains add myflow.localhost 127.0.0.1:3000 --replace f domains add api.myflow.localhost 127.0.0.1:8780 --replace ``` ## Start native domains engine ```bash f domains --engine native up f domains --engine native doctor f domains list ``` Optional default (so you can run `f domains up` without `--engine native`): ```bash export FLOW_DOMAINS_ENGINE=native ``` ## Start myflow dev ```bash cd ~/code/myflow f dev ``` Then open: - `http://myflow.localhost` Notes: - `f dev` runs web on `127.0.0.1:3000` and API on `127.0.0.1:8780`. - myflow dev uses `/api` proxy to the local API port by default. - `api.myflow.localhost` is useful for direct API checks, but the web app does not require it in the default `f dev` path. ## One-command mode (`f up` / `f down`) In `~/code/myflow/flow.toml`, add: ```toml [lifecycle] up_task = "dev" [lifecycle.domains] host = "myflow.localhost" target = "127.0.0.1:3000" engine = "native" remove_on_down = false stop_proxy_on_down = false ``` Then use: ```bash cd ~/code/myflow f up f down ``` `f down` will use task `down` if defined; otherwise it falls back to killing all running Flow-managed processes for the current project. ## Logs inside myflow Use built-in myflow pages: - `http://myflow.localhost/processes` - process state - start/stop actions - live per-process logs - `http://myflow.localhost/logs` - focused log stream view These pages query the local Flow daemon API (`http://127.0.0.1:9050`). ## Native domains runtime files Native engine state is under: - `~/.config/flow/local-domains/routes.json` - `~/.config/flow/local-domains/domainsd.pid` - `~/.config/flow/local-domains/domainsd.log` - `~/.config/flow/local-domains/domainsd-cpp` Quick checks: ```bash curl -H 'Host: myflow.localhost' http://127.0.0.1/ curl http://127.0.0.1/_flow/domains/health tail -f ~/.config/flow/local-domains/domainsd.log ``` ## Common failures 1. `myflow.localhost` refuses connection ```bash f domains --engine native doctor lsof -nP -iTCP:80 -sTCP:LISTEN ``` Then ensure `f dev` is running in `~/code/myflow`. 2. Wrong app opens on `myflow.localhost` ```bash f domains list f domains add myflow.localhost 127.0.0.1:3000 --replace ``` 3. Browser console shows `Invalid base URL: /api` - update to latest `~/code/myflow` (this is handled in current auth client path resolution), - hard-refresh browser cache, - if running web manually (not via `f dev`), set an absolute API base, for example: ```bash VITE_API_URL=http://api.myflow.localhost ``` ## Stop ```bash f domains --engine native down ``` For launchd-managed native mode on macOS, use: ```bash cd ~/code/flow sudo ./tools/domainsd-cpp/uninstall-macos-launchd.sh ``` ================================================ FILE: docs/new-branch.md ================================================ # New Branch (Flow + jj) Use this when you want a clean feature branch quickly with Flow (which syncs with jj under the hood). ## Goal Create a branch from latest `origin/main` (or preferred remote trunk), keep local state safe, and verify final state. ## Recommended command flow ```bash # 1) Start from repo root cd <repo> # 2) Sync trunk f sync # 3) Try Flow-native switch first f switch <branch-name> # 4) If Flow says "Branch '<name>' not found locally or on remotes", create from current HEAD git switch -c <branch-name> # 5) Verify branch, upstream, and base commit git rev-parse --abbrev-ref HEAD git for-each-ref --format='%(refname:short) %(upstream:short)' refs/heads/<branch-name> git status --short --branch git log -1 --oneline # 6) Optional: publish branch and set tracking now git push -u origin <branch-name> ``` ## Reusable AI context template Paste this in requests when you want branch creation handled consistently: ```md Use Flow-native branch creation in this repo: 1. Run `f sync`. 2. Try `f switch <branch-name>`. 3. If Flow says branch is not found locally/remotely, run `git switch -c <branch-name>`. 4. Verify branch name, upstream/tracking, and clean working tree (`git status --short --branch`). 5. Report exact commands run, final branch, and HEAD commit. Constraints: - Keep unrelated local changes untouched. - Do not use destructive git commands. - If `f sync` is blocked by commit queue, list queue and clear stale entries safely before retrying. ``` ## Notes - `f switch` preserves safety snapshots and stashes by default. - `f switch` may create `f-switch-save/<branch>-<timestamp>` even when it fails to find the target branch; this is expected safety behavior. - `f switch` now searches all configured Git remotes (not just `upstream`/`origin`) when resolving a missing local branch. - Today, `f switch` may fail for a brand-new local-only branch name; use the documented fallback. - If you intentionally need a different base, switch to that base first, then run `f switch <branch-name>`. - If your default trunk is `upstream/main`, use `--remote upstream`. - If you plan to open a PR soon, run `git push -u origin <branch-name>` right after creation so tracking is set. ================================================ FILE: docs/new-pr.md ================================================ # New PR (Flow + jj) Use this when you want to create and iterate on a PR with Flow while keeping jj state clean. ## Goal Create a PR from a queued commit/bookmark, avoid accidental extra commits, and keep PR metadata easy to edit. ## Recommended command flow ```bash # 1) Start from repo root cd ~/code/org/linsa/linsa # 2) Sync trunk f sync # 3) Ensure jj is initialized (first time in repo) f jj init # 4) Create/track a feature bookmark (once per feature) f jj bookmark create <bookmark-name> --track # 5) Make changes, run checks, then queue commit (no push) f commit --queue -m "<what changed>" # 6) Create PR from queued commit (no new commit) f pr --no-commit --base main # 7) Edit PR title/body locally and auto-sync on save f pr open edit ``` Important formatting rule: - Do not pass multi-line PR body text as a quoted CLI string with escaped `\n`. - Use file-based markdown editing (`f pr open edit`) or `gh pr edit --body-file <file>`. ## Update loop for follow-up commits ```bash # After additional changes f commit --queue -m "<follow-up>" f pr --no-commit --base main f pr open edit ``` ## Reusable AI context template Paste this in requests when you want PR creation handled consistently: ```md Use Flow + jj PR workflow in this repo: 1. Run `f sync`. 2. Ensure jj is initialized (`f jj init`) and bookmark is tracked. 3. Commit with `f commit --queue` (no direct push). 4. Create/update PR with `f pr --no-commit --base main`. 5. Open PR editor with `f pr open edit` and sync title/body. 6. Report exact commands run and the final PR URL. Constraints: - Keep unrelated local changes untouched. - Do not use destructive git commands. - Do not create duplicate commits when creating PRs. ``` ## Notes - `f pr` without `--no-commit` will stage/commit before creating the PR. - `f commit --queue` is the safest default for a review-first loop. - If your base branch is not `main`, always pass `--base <branch>`. - For bookmark-heavy workflows, run `f jj sync --bookmark <bookmark-name>` to fetch/rebase/push bookmark state. ================================================ FILE: docs/outdated-readme.md ================================================ <!-- todo: remove/update this as its not up to date on whats in code repo --> ## Install ```bash curl -fsSL https://raw.githubusercontent.com/nikivdev/flow/main/scripts/install.sh | bash ``` This downloads prebuilt binaries from GitHub releases. Falls back to building from source if no binary is available for your platform. **Environment variables:** - `FLOW_VERSION=v0.1.0` - Install specific version - `FLOW_BIN_DIR=/custom/path` - Custom install location (default: `~/.local/bin`) After install, add to your PATH if needed: ```bash export PATH="$HOME/.local/bin:$PATH" ``` ## Upgrade ```bash f upgrade # Upgrade to latest version f upgrade --dry-run # Check what would be upgraded f upgrade v0.2.0 # Install specific version ``` ## Short summary For longer list of features available, see [docs/features.md](docs/features.md). Currently [this thread](https://x.com/nikivdev/status/1997297174074499247) gives good overview of how you can use this tool to move fast with AI. I would suggest to open the repo and ask questions with claude code or codex how to make best use of the app. What it does well now is that you can define tasks in config. Like so: ``` version = 1 name = "ts" [deps] fast = "github.com/nikivdev/fast" [[tasks]] name = "setup" command = "bun i" [[tasks]] name = "dev" command = "bun --watch run.ts" [[tasks]] name = "commit" command = "fast commitPush" description = "Commit with AI" dependencies = ["fast"] delegate_to_hub = true ``` Above is from [ts repo](https://github.com/nikivdev/ts). Then you can run `f` to fuzzy search through tasks to run. Or do `f <task>` to run specific task. If you setup [LM Studio](https://lmstudio.ai) & load MLX model like OpenAI 20B one, you can even make mistakes in `f <task>` and it would do a tool call match for you. All flow tasks are traced for output/error after you ran them. `f last-cmd` would return the last commands output. In practice you can do this, open [warp](https://warp.dev) or [cursor](https://cursor.com) and then just ask agents things, you can literally say, `make me a flow.toml task to do..` then you run the task with `f <task>` or `f rerun` to rerun last ran task. And on errors, what I do at least is have [Keyboard Maestro](https://keyboardmaestro.com/main) macro to paste the output instantly into the agent. This way the feedback loop is insanely tight and you can iterate very fast. Below readme is mostly generated with AI so feel free to ignore. I also use `flow` CLI to manage [hubs](#hub) but its experimental as the hub implementation I am running is closed code. The big idea is that `flow` just keeps the hub alive and that's it. ## Building from Source ```bash # Clone the repo git clone https://github.com/nikivdev/flow.git cd flow # Build cargo build --release --bin f --bin lin # Install cp target/release/f ~/.local/bin/ cp target/release/lin ~/.local/bin/ ln -sf ~/.local/bin/f ~/.local/bin/flow ``` ## Creating Releases For maintainers to create new releases: ```bash # Build release binary cargo build --release --bin f --bin lin # Create tarball (adjust os/arch as needed) mkdir -p dist cd target/release tar -czvf ../../dist/flow_v0.2.0_darwin_arm64.tar.gz f lin # Create GitHub release with assets f release create v0.2.0 -a dist/flow_v0.2.0_darwin_arm64.tar.gz --generate-notes ``` Or use the `gh` CLI directly: ```bash gh release create v0.2.0 --generate-notes dist/flow_v0.2.0_darwin_arm64.tar.gz ``` ## Configuration Once you have `f` (also available as `flow`) CLI, create `flow.toml` in your project: ```toml version = 1 name = "myproject" [[tasks]] name = "setup" command = "npm install" description = "Install dependencies" [[tasks]] name = "dev" command = "npm run dev" description = "Start development server" [[tasks]] name = "test" command = "npm test" ``` Run `f` in your project to fuzzy search through tasks, or `f <task>` to run directly. ## Commands **Tasks:** - `f` — Interactive fuzzy picker for tasks - `f <task>` — Run a specific task - `f tasks` — List all tasks from `flow.toml` - `f init` — Scaffold a starter `flow.toml` - `f rerun` — Re-run the last task **Git & Publishing:** - `f commit` / `f c` — AI-powered commit with code review - `f sync` — Pull, merge upstream, push - `f publish` — Create GitHub repository from current folder - `f release create` — Create GitHub release with assets **Self-management:** - `f upgrade` — Upgrade to latest version - `f doctor` — Check system dependencies **Other:** - `f search` / `f s` — Fuzzy search global tasks - `f hub start|stop` — Manage the lin hub daemon ## Hub There is component in flow that is a hub. It's a daemon that does things. Flow is not responsible for what the daemon does, all it does is it makes sure this daemon runs and in future perhaps auto heals or restarts as the idea is that the daemon should always be running. There is an implementation of such hub built in private called `lin`. Will be possible to use soon, for now it's being tested in private as there are bugs. The goal of lin is to declaratively specify servers to run and trace all terminal I/O. In future more. Flow keeps a pointer to your production lin binary at `~/.config/flow/hub-runtime.json` (written by `lin register`); `f hub start` health-checks `http://127.0.0.1:9050/health` and launches that registered binary in daemon mode (passing `--config` if you supply one). If you want to experiment with a dev build, run it on another port so Flow can keep the production copy alive on the default port. There are also plans for flow to handle communication between hubs. But flow will always try to abstract away the job of the actual hub to the hub itself as the hub can do many things. Right now it is assumed there is only 1 hub but in future there could be multiple hubs in theory. I like to think of flow as a program that is first top in class project manager with AI deeply embedded. But also as a small kubernetes like orchestrator of servers that run on the OS. Perhaps it will also handle the job of ingesting and streaming data from these hubs. i.e. in theory it can protect the user host from external potentially malicious hubs by making sure the hub has limited rights to do things. ## Current state Lightweight CLI that reads project-local `flow.toml` files, surfaces tasks, and delegates long-running background work to the `lin` hub. Flow itself no longer tries to manage servers, watchers, or tracing—that all belongs to `lin`. Flow’s job is to keep the hub running and give you a fast task entry point. ### Tasks - Put a `flow.toml` next to your project. - Define tasks (see example at the top of this README). - Run `f` to fuzzy-pick a task (falls back to a numbered list if `fzf` is missing) or `f <task>` / `f run <task>` to execute directly. ### Hub delegation - `lin` is the hub implementation; it reads `~/.config/lin/config.ts` (or `config.toml`) and owns servers/watchers/tracing. - Flow does not read `~/.config/flow/flow.toml` anymore; point `lin` at its config and keep it running (e.g., `lin -- daemon` or `lin hub start` if you use the helper). - Future Flow features will talk to the hub over HTTP instead of reimplementing those capabilities. ================================================ FILE: docs/pr-edit-watcher.md ================================================ --- title: PR Edit Watcher --- # PR Edit Watcher Flow supports editing GitHub PR title/body from local Markdown files stored in `~/.flow/pr-edit/`. There are two modes: 1. One-shot editor + sync loop: `f pr open edit` 2. Always-on background watcher (recommended): `f server` ## Always-On Watcher (f server) When `f server` is running, it starts a lightweight watcher that: - Watches `~/.flow/pr-edit/` (non-recursive) - Debounces per-file changes (about 1.25s after the last write) - Parses title/body from the markdown - Updates the PR via GitHub REST (PATCH issue) - Writes status to `~/.flow/pr-edit/status.json` Endpoints: - `GET /pr-edit/status` - `POST /pr-edit/rescan` Default server URL: `http://127.0.0.1:9060` Example: ```sh curl -s http://127.0.0.1:9060/pr-edit/status ``` ## File Format Each file must map to a PR. The preferred mapping is YAML frontmatter: ```md --- repo: owner/repo pr: 123 --- # Title My PR title # Description Body goes here. ``` If the frontmatter is missing, Flow may fall back to `~/.flow/pr-edit/.index.json` (managed by `f pr open edit`). ## Title/Body Parsing - Title: the first non-empty line under `# Title` - Body: everything under `# Description` (verbatim) ## Using f pr open edit `f pr open edit`: - Finds the open PR for the current branch (fallback: queued commit PR) - Creates `~/.flow/pr-edit/<project>-<pr>.md` if missing - Ensures the file contains PR frontmatter - Opens the file in Zed Preview - Starts a foreground watcher that syncs on save (Ctrl-C to stop) ## Status JSON `~/.flow/pr-edit/status.json` is written by the always-on watcher and can be used to build a UI. States: - `unknown`: file exists but no PR mapping - `syncing`: change detected and being pushed - `clean`: last sync succeeded, content matches last pushed digest - `error`: last sync failed (see `last_error`) ## Auth The watcher uses `gh auth token` once and caches the token in memory. If syncing fails with auth errors, run: ```sh gh auth status gh auth login ``` ## Debugging Start the server in foreground with debug prints for the PR watcher: ```sh FLOW_PR_EDIT_DEBUG=1 f server foreground ``` If the watcher failed to start, `GET /pr-edit/status` returns HTTP 503 with a `detail` field. ================================================ FILE: docs/private-fork-flow.md ================================================ # Private Fork Flow Runbook Use this as the default AI-safe procedure when work must be pushed to a private fork, not public `origin`. ## Goal - Keep upstream/public remotes for syncing. - Push writable changes to a private remote. - Make `f sync --push` and commit flows consistently target the private remote. ## One-Time Setup Per Repo 1. Add private remote. ```bash cd <repo-dir> git remote add <private-remote> git@github.com:<your-user>/<repo>-i.git git fetch <private-remote> ``` 2. Set Flow writable remote in `flow.toml`. ```toml [git] remote = "<private-remote>" ``` 3. Verify remote map. ```bash git remote -v ``` Expected pattern: - `origin` and/or `upstream` are read/sync sources. - `<private-remote>` is writable push target. ## Standard Push Procedure ```bash cd <repo-dir> git status --short --branch git diff --stat git diff f commit --slow --review-model codex-high f sync --push ``` Flow behavior: - `f sync --push` uses `[git].remote` when configured. - Fallback order is `[git].remote`, then legacy `[jj].remote`, then `origin`. ## AI Trigger Contract Use this exact phrase when you want review-first behavior: `analyze diff commit and push` Expected assistant behavior: 1. Run `git status --short --branch`, `git diff --stat`, `git diff`. 2. Produce a findings-first review (ordered by severity, with file references). 3. If unresolved P1/P2 issues exist, stop before commit/push and fix or ask for override. 4. Run `f commit --slow --review-model codex-high`. 5. Run `f sync --push`. 6. Report which remote received the push (`[git].remote` or fallback `origin`). ## AI Guardrails (Must Follow) - Never push before reviewing `git status --short --branch` and `git diff --stat`. - Never include unrelated generated artifacts in the commit. - If the tree is noisy, create smaller focused commits before push. - If the remote target is unclear, stop and verify `flow.toml` `[git].remote` plus `git remote -v`. ## Quick Validation ```bash git config --get branch.$(git rev-parse --abbrev-ref HEAD).remote || true git remote get-url <private-remote> ``` Then run: ```bash f sync --push ``` ## Related Docs - `docs/commands/sync.md` - `docs/flow-toml-spec.md` - `docs/private-mirror-sync-workflow.md` - `docs/commands/upstream.md` ================================================ FILE: docs/private-mirror-sync-workflow.md ================================================ # Private Mirror Sync Workflow (Upstream/Public + Private Fork) Use this when you work in a public fork clone locally but want to keep your full WIP history in a private mirror repo. Example mapping used here: - Local repo: `~/repos/pqrs-org/Karabiner-Elements-user-command-receiver` - Public remotes: - `origin` = `nikivdev/Karabiner-Elements-user-command-receiver` - `upstream` = `pqrs-org/Karabiner-Elements-user-command-receiver` - Private mirror remote: - `private` = `nikivdev/Karabiner-Elements-user-command-receiver-i` ## Goal 1. Move feature-branch work onto `main` locally. 2. Sync with latest `origin/main`. 3. Push local `main` to a private fork/mirror. ## Recommended commands ### 1) Save dirty working tree and move branch commits to `main` ```bash # from repo root git stash push -u -m "move-to-main" git switch main git merge --ff-only <feature-branch> ``` If `--ff-only` fails, do a normal merge or cherry-pick intentionally. ### 2) Sync with `origin/main` If you want strict origin-only sync (no upstream automation): ```bash git fetch origin --prune git rebase origin/main ``` Then reapply stash: ```bash git stash apply stash@{0} ``` ### 3) Commit only intended files Avoid runtime artifacts (`out/logs/*`, todo scratch files, etc.). ```bash git add -A -- ':!out/logs/cli.log' ':!out/logs/trace.log' ':!.ai/todos/todos.json' git commit -m "<message>" ``` ### 4) Create private mirror repo and push ```bash # one-time creation gh repo create nikivdev/<repo>-i --private --source=. --remote=private --disable-wiki # publish branch git push -u private main ``` ## Flow-specific notes (`f sync`) `f sync` can use jj integration and may rebase against upstream depending on repo setup. That is useful in normal fork workflows, but if you need strict origin-only syncing for a private mirror flow, prefer explicit Git commands: ```bash git fetch origin --prune git rebase origin/main ``` Then use Flow for commit/review as usual. ## If `f sync` creates jj conflict artifacts Symptoms: - `jj` conflict commits - files like `.jjconflict-base-*` / `.jjconflict-side-*` Recovery pattern: 1. stash uncommitted work (`git stash push -u`) 2. create clean branch from `origin/main` 3. cherry-pick your intended commits 4. reapply stash 5. continue from clean branch and move pointer back to `main` ```bash git stash push -u -m "recovery" git fetch origin --prune git switch -c main-clean origin/main git cherry-pick <commit1> <commit2> git stash apply <stash-with-real-work> git branch -f main main-clean git switch main ``` ## Optional: keep `main` tracking private mirror If this repo is now private-first for your local work: ```bash git push -u private main ``` and keep `origin`/`upstream` as fetch sources for rebases. ================================================ FILE: docs/private-repo-fast.md ================================================ # Fast Private Repo Creation From An Existing Local Checkout Use this when you already have code locally and want a private GitHub repo quickly. This guide covers two different cases: 1. the current folder should become its own private GitHub repo 2. the current folder already has `origin`/`upstream`, and you want an extra private share remote like `<repo>-i` The important distinction: - `f publish` works from the current directory and is not tied to `~/repos` - `f repos clone` is the command that cares about `~/repos` So yes, this works from places like: - `~/repos/viperrcrypto/Siftly` - `~/code/flow-extension` ## Fastest path: current folder becomes a private repo Use this when: - the folder is your project - you want GitHub to become `origin` - you do not need to preserve some existing public `origin`/`upstream` setup From the repo root: ```bash f publish -y --private ``` This is the fastest default. What it does: - checks `gh` auth - initializes git if needed - creates an initial commit if the repo has none - creates the private GitHub repo - wires/pushes the current project Example from outside `~/repos`: ```bash cd ~/code/flow-extension f publish -y --private ``` That is the recommended path for repos like `~/code/flow-extension`. ## Fast private share repo while keeping existing origin/upstream Use this when: - the repo already has a real public `origin` or `upstream` - you want a separate private mirror/share repo - you do not want to disturb existing remotes This is the right pattern for repos like: ```text ~/repos/viperrcrypto/Siftly ``` ### Recommended command sequence 1. Make sure your intended work is committed. ```bash git status --short --branch git add <intended files> git commit -m "<message>" ``` 2. Sync with `origin/main` if needed. ```bash git fetch origin main git rev-parse HEAD origin/main ``` If you need to move your work on top of `origin/main`, do that intentionally before publishing. 3. Create the private repo and add it as a separate remote. ```bash gh repo create nikivdev/<repo>-i --private --disable-wiki --source=. --remote=private ``` 4. Push your current commit or branch to the new private repo. ```bash git push -u private HEAD:main ``` Example: ```bash cd ~/repos/viperrcrypto/Siftly gh repo create nikivdev/Siftly-i --private --disable-wiki --source=. --remote=private git push -u private HEAD:main ``` This leaves: - `origin` alone - `upstream` alone - `private` as the new share/mirror remote ## When to use `f publish` vs `gh repo create` Use `f publish` when: - you want the current folder to become the repo - you want the simplest path - the repo is new or self-owned Use `gh repo create ... --remote=private` when: - the checkout already tracks a public repo - you want a separate private mirror - you want to share WIP without changing `origin` ## Safe default for a repo with an existing public origin If a repo already has `origin` and you are not completely sure what to do, use this: ```bash git remote -v git fetch origin main gh repo create nikivdev/<repo>-i --private --disable-wiki --source=. --remote=private git push -u private HEAD:main ``` That is the safest default for “share this work privately with another dev”. ## Optional: make Flow push to the private remote by default If you want future `f sync --push` calls to go to the private repo instead of `origin`, add this to the repo’s `flow.toml`: ```toml [git] remote = "private" ``` Only do this if the repo is now private-first for your workflow. If you just want a one-off share snapshot, skip this. ## Common examples ### Example: private repo from `~/code/flow-extension` ```bash cd ~/code/flow-extension f publish -y --private ``` ### Example: private share mirror from `~/repos/viperrcrypto/Siftly` ```bash cd ~/repos/viperrcrypto/Siftly git fetch origin main gh repo create nikivdev/Siftly-i --private --disable-wiki --source=. --remote=private git push -u private HEAD:main ``` ## Troubleshooting ### `gh` is not authenticated Run: ```bash gh auth login gh auth status ``` ### Repo already exists If the private repo already exists, skip creation and just wire or verify the remote: ```bash git remote add private git@github.com:nikivdev/<repo>-i.git git push -u private HEAD:main ``` If `private` already exists: ```bash git remote -v git push -u private HEAD:main ``` ### I am outside `~/repos` That is fine. `f publish` operates on the current folder, not on `~/repos`. ### I do not want to push uncommitted changes Good. Commit first. For existing repos with real history, do not rely on auto-magic here. Make the commit you want to share, then push that exact commit. ## Related docs - [commands/publish.md](commands/publish.md) - [commands/repos.md](commands/repos.md) - [private-fork-flow.md](private-fork-flow.md) - [private-mirror-sync-workflow.md](private-mirror-sync-workflow.md) ================================================ FILE: docs/proxyx-design.md ================================================ # proxyx: Zero-Cost Traced Reverse Proxy for Flow A lightweight reverse proxy with always-on observability, designed for macOS development. ## Design Principles 1. **Zero-cost tracing** - mmap ring buffer, no allocations per request 2. **Flow-native** - integrates with `f` commands and flow.toml 3. **AI-powered naming** - suggests proxy names from port/process info 4. **macOS-optimized** - no Docker/K8s complexity 5. **Dev-time intelligence** - traces inform AI agents writing code --- ## How Traces Help During Development (Rise Example) Rise has multiple services that need coordination during development: ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Development Session │ │ │ │ Claude Code ◄──── reads traces ────► proxyx ring buffer │ │ │ ▲ │ │ │ writes code │ records all requests │ │ ▼ │ │ │ ┌─────────┐ ┌─────────┐ ┌───────────┴───────────┐ │ │ │ web │───▶│ daemon │───▶│ zai/xai/cerebras/etc │ │ │ │ :5173 │ │ :7654 │ └───────────────────────┘ │ │ └─────────┘ └─────────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌─────────┐ │ │ └────────▶│ api │ │ │ │ :8787 │ │ │ └─────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` ### Use Case 1: "Why is my AI call slow?" When you're writing code and notice latency, instead of hunting through logs: ```bash f proxy last --target daemon # Request 3f2a: # Path: /v1/chat/completions # Provider: zai → cerebras (fallback) # Latency: 4200ms (provider: 4150ms, overhead: 50ms) # Tokens: 1200 in, 340 out # Error: zai timeout after 3000ms, retried cerebras ``` **AI agent can read this** and suggest: "zai is timing out - switch default provider to cerebras in your session" ### Use Case 2: "What API calls does this component make?" When editing a component, see exactly what requests it triggers: ```bash f proxy trace --since "10s ago" --source web # TIME METHOD PATH STATUS LATENCY TARGET # 14:32:01 POST /v1/chat/completions 200 120ms daemon # 14:32:01 GET /api/user/profile 200 8ms api # 14:32:02 POST /api/mutations 200 45ms api ``` **AI agent can correlate**: "Your UserProfile component makes 3 requests on mount - the chat completion is redundant here" ### Use Case 3: "Debug failing mutation" When Effect mutation fails, trace shows the full picture: ```bash f proxy trace --errors --last 5 # Request a3f1: # Path: /api/mutations # Status: 500 # Upstream response: {"_tag":"ParseError","message":"Expected string at path.name"} # Request body hash: 0x3f2a... (see f proxy body a3f1) ``` **AI agent sees**: typed error from Effect schema validation, can fix the code directly ### Use Case 4: "Correlation across services" Trace ID propagates through all services: ```bash f proxy trace --id abc123 # Trace abc123 (total: 340ms): # 14:32:01.000 web → daemon POST /v1/chat/completions (started) # 14:32:01.050 daemon → zai POST /chat/completions (timeout 3000ms) # 14:32:04.050 daemon → cerebras POST /chat/completions (fallback) # 14:32:04.200 cerebras → daemon 200 OK (150ms) # 14:32:04.210 daemon → web 200 OK (streaming start) # 14:32:04.340 daemon → web streaming complete (340ms total) ``` ### Use Case 5: "What changed between working and broken?" Compare request patterns before/after a code change: ```bash f proxy diff --before "5 min ago" --after "now" # New requests: # + POST /api/mutations (didn't exist before) # Changed requests: # ~ GET /api/user/profile: added header X-Cache-Bust # Missing requests: # - GET /api/user/settings (no longer called) ``` --- ## Integration with Claude Code / AI Agents The key insight: **traces are structured data AI agents can consume**. ```rust // In Claude Code's context, expose trace summary pub struct TraceContext { pub recent_errors: Vec<TraceRecord>, // Last 5 errors pub slow_requests: Vec<TraceRecord>, // p99 > 500ms pub request_patterns: HashMap<String, u32>, // Path -> count pub provider_health: HashMap<String, ProviderStats>, } impl TraceContext { /// Called by AI agent to understand current state pub fn summarize(&self) -> String { format!( "Recent errors: {}\nSlow requests: {}\nMost called: {}", self.recent_errors.len(), self.slow_requests.len(), self.request_patterns.iter().max_by_key(|&(_, v)| v).map(|(k, _)| k).unwrap_or("none") ) } } ``` ### Agent-Readable Trace File In addition to binary ring buffer, write agent-friendly summary: ``` ~/.config/flow/proxy/trace-summary.json { "last_updated": 1706000000, "session": { "started": 1705990000, "requests": 1234, "errors": 5, "avg_latency_ms": 45 }, "recent_errors": [ { "time": "14:32:01", "path": "/api/mutations", "status": 500, "error": "ParseError: Expected string at path.name", "suggestion": "Check schema validation in mutations endpoint" } ], "slow_requests": [ { "time": "14:31:45", "path": "/v1/chat/completions", "latency_ms": 4200, "reason": "Provider fallback: zai → cerebras" } ], "provider_status": { "zai": { "healthy": false, "last_error": "timeout", "error_rate": "40%" }, "cerebras": { "healthy": true, "avg_latency_ms": 150 } } } ``` Claude Code can read this file and proactively suggest fixes: > "I notice zai has a 40% error rate in the last 5 minutes. Should I switch your default provider to cerebras?" --- ## Rise-Specific Configuration ```toml # ~/code/rise/flow.toml [proxy] trace_summary = true # Write agent-readable JSON summary trace_interval = "1s" # Update summary every second [[proxies]] name = "daemon" target = "localhost:7654" # Capture request/response bodies for AI endpoints capture_body = true capture_body_max = "64KB" [[proxies]] name = "api" target = "localhost:8787" # Correlate with Effect trace events effect_trace_header = "X-Trace-Id" [[proxies]] name = "web" target = "localhost:5173" # Don't capture static assets exclude_paths = ["/assets/*", "*.js", "*.css"] ``` ## Integration with Rise's Existing Tracing Rise already has two tracing mechanisms: 1. **Daemon logs** (`/logs` endpoint) - in-memory, lost on restart 2. **Effect Trace service** - writes to JSONL file + HTTP endpoint proxyx unifies these by: 1. **Intercepting all HTTP** - captures what daemon logs miss (non-AI requests) 2. **Correlating trace IDs** - links Effect mutations to HTTP requests 3. **Persisting in ring buffer** - survives restarts, zero-cost 4. **Exposing to AI agents** - structured summary for Claude Code ``` Before (fragmented): web → daemon (logged in daemon memory, lost on restart) web → api (logged in Effect Trace JSONL, separate file) No correlation between them After (unified): web → proxyx → daemon (ring buffer + summary JSON) └──────→ api (ring buffer + summary JSON) All requests correlated by trace ID, readable by AI agents ``` ### Trace ID Propagation proxyx generates trace IDs and propagates them: ``` Request from web: → proxyx adds X-Trace-Id: abc123 (if not present) → forwards to daemon with X-Trace-Id: abc123 → daemon logs include trace_id: abc123 → Effect Trace service receives X-Trace-Id header → All logs correlate to abc123 ``` This means `f proxy trace --id abc123` shows the complete journey. --- ## Architecture ``` ┌─────────────────────────────────────────────────────────────────────┐ │ flow.toml │ │ │ │ [[proxies]] │ │ name = "api" # AI can suggest this │ │ listen = ":8080" # or auto-assign │ │ target = "localhost:3000" │ │ host = "api.local" # optional host-based routing │ │ │ │ [[proxies]] │ │ name = "docs" │ │ target = "localhost:4000" │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ proxyx daemon (spawned by flow supervisor) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ │ Listener │───▶│ Router │───▶│ Backend Pool │ │ │ │ (hyper) │ │ (path/host) │ │ (crossbeam queue) │ │ │ └──────────────┘ └──────────────┘ └────────────────────────┘ │ │ │ │ │ │ │ ┌──────────────────────────────┘ │ │ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐│ │ │ Trace Ring Buffer (mmap) ││ │ │ ~/.config/flow/proxy/trace.<pid>.bin ││ │ │ ││ │ │ Header (64 bytes): ││ │ │ magic: "PROXYTRC" ││ │ │ version: 1 ││ │ │ capacity: N ││ │ │ write_index: AtomicU64 ││ │ │ ││ │ │ Records (128 bytes each): ││ │ │ [ts_ns, req_id, method, status, latency_us, ││ │ │ bytes_in, bytes_out, target_idx, path_hash, path_prefix] ││ │ └─────────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────────┘ ``` ## Trace Record Structure ```rust const TRACE_MAGIC: &[u8; 8] = b"PROXYTRC"; const TRACE_VERSION: u32 = 1; const TRACE_RECORD_SIZE: usize = 128; const TRACE_PATH_BYTES: usize = 64; #[repr(C)] struct TraceHeader { magic: [u8; 8], version: u32, record_size: u32, capacity: u64, write_index: AtomicU64, // Target table (name -> index mapping written at init) target_count: u32, _reserved: [u8; 28], } #[repr(C)] struct TraceRecord { ts_ns: u64, // Monotonic timestamp req_id: u64, // Unique request ID (atomic counter) method: u8, // 1=GET, 2=POST, 3=PUT, 4=DELETE, etc. status: u16, // HTTP status code _pad: u8, latency_us: u32, // Response time in microseconds bytes_in: u32, // Request body size bytes_out: u32, // Response body size target_idx: u8, // Index into target table path_len: u8, _pad2: [u8; 2], path_hash: u64, // FNV-1a hash of full path path: [u8; 64], // Path prefix (truncated if longer) client_ip: [u8; 16], // IPv4 (4 bytes) or IPv6 (16 bytes) upstream_latency_us: u32, _reserved: [u8; 4], } ``` ## Components ### 1. Proxy Core (Pingora-inspired) ```rust // Simplified from Pingora's ProxyHttp trait pub trait ProxyHandler: Send + Sync { /// Called before forwarding request fn request_filter(&self, req: &mut Request, ctx: &mut ProxyCtx) -> Result<()> { Ok(()) } /// Select upstream target fn upstream_peer(&self, req: &Request, ctx: &mut ProxyCtx) -> Result<&Backend>; /// Called after receiving response fn response_filter(&self, resp: &mut Response, ctx: &mut ProxyCtx) -> Result<()> { Ok(()) } /// Called after request completes (success or failure) fn logging(&self, req: &Request, resp: Option<&Response>, ctx: &ProxyCtx) { // Default: write to trace ring buffer } } pub struct ProxyCtx { pub req_id: u64, pub start_time: Instant, pub upstream_connect_time: Option<Duration>, pub target_idx: u8, } ``` ### 2. Connection Pool (Pingora-inspired, simplified) ```rust use crossbeam::queue::ArrayQueue; pub struct ConnectionPool { // Lock-free queue for hot connections (sized for dev workloads) hot: ArrayQueue<PooledConnection>, // Target address addr: SocketAddr, // Pool stats (atomic counters) reused: AtomicU64, created: AtomicU64, } impl ConnectionPool { pub fn new(addr: SocketAddr, capacity: usize) -> Self { Self { hot: ArrayQueue::new(capacity), addr, reused: AtomicU64::new(0), created: AtomicU64::new(0), } } pub async fn get(&self) -> Result<Connection> { // Try hot queue first (lock-free) if let Some(conn) = self.hot.pop() { if conn.is_alive() { self.reused.fetch_add(1, Ordering::Relaxed); return Ok(conn.into_connection()); } } // Create new connection self.created.fetch_add(1, Ordering::Relaxed); Connection::new(self.addr).await } pub fn put(&self, conn: Connection) { let pooled = PooledConnection::from(conn); // Best effort - if queue is full, connection is dropped let _ = self.hot.push(pooled); } } ``` ### 3. Trace Ring Buffer ```rust use std::sync::atomic::{AtomicU64, Ordering}; use std::ptr::write_unaligned; pub struct TraceBuffer { header: *mut TraceHeader, records: *mut u8, capacity: u64, req_counter: AtomicU64, } impl TraceBuffer { /// Record a completed request (zero allocations) #[inline] pub fn record(&self, record: &TraceRecord) { let idx = unsafe { (*self.header).write_index.fetch_add(1, Ordering::Relaxed) }; let slot = (idx % self.capacity) as usize; let dst = unsafe { self.records.add(slot * TRACE_RECORD_SIZE) as *mut TraceRecord }; unsafe { write_unaligned(dst, *record) }; } /// Get next request ID #[inline] pub fn next_req_id(&self) -> u64 { self.req_counter.fetch_add(1, Ordering::Relaxed) } } ``` ### 4. Router ```rust pub struct Router { // Host -> target index host_routes: HashMap<String, usize>, // Path prefix -> target index path_routes: Vec<(String, usize)>, // Default target default_target: Option<usize>, // All backends backends: Vec<Backend>, } pub struct Backend { pub name: String, pub addr: SocketAddr, pub pool: ConnectionPool, } impl Router { pub fn route(&self, req: &Request) -> Option<&Backend> { // 1. Check host header if let Some(host) = req.headers().get("host") { if let Some(&idx) = self.host_routes.get(host.to_str().ok()?) { return Some(&self.backends[idx]); } } // 2. Check path prefix let path = req.uri().path(); for (prefix, idx) in &self.path_routes { if path.starts_with(prefix) { return Some(&self.backends[*idx]); } } // 3. Default self.default_target.map(|idx| &self.backends[idx]) } } ``` ## CLI Commands ```bash # List active proxies f proxy # Output: # NAME LISTEN TARGET REQS ERRORS LATENCY(p99) # api :8080 localhost:3000 1.2k 0 12ms # docs :8081 localhost:4000 340 2 8ms # Add a proxy (AI suggests name) f proxy add localhost:3000 # Detected: node process, cwd=~/code/myapi # Suggested name: "myapi" [Y/n/custom]: # Add with explicit name f proxy add localhost:3000 --name api # View recent requests f proxy trace # Output (tail of ring buffer): # TIME REQ_ID METHOD PATH STATUS LATENCY TARGET # 14:32:01 a3f2 GET /api/users 200 12ms api # 14:32:01 a3f3 POST /api/login 401 8ms api # 14:32:02 a3f4 GET /docs/intro 200 4ms docs # View last request details f proxy last # Request a3f4: # Method: GET # Path: /docs/intro # Status: 200 # Latency: 4ms # Upstream: 3ms # Bytes: 0 in, 4.2KB out # Follow trace in real-time f proxy trace -f # Filter by target f proxy trace --target api # Stop proxy daemon f proxy stop ``` ## Flow.toml Schema ```toml [proxy] # Global proxy settings listen = ":8080" # Default listen address trace_size = "16MB" # Ring buffer size trace_dir = "~/.config/flow/proxy" [[proxies]] name = "api" target = "localhost:3000" # Optional: host-based routing host = "api.local" # Optional: path prefix routing path = "/api" # Optional: health check health = "/health" health_interval = "10s" [[proxies]] name = "docs" target = "localhost:4000" path = "/docs" ``` ## AI Naming Integration When `f proxy add <target>` is called without `--name`: ```rust pub struct PortInfo { pub port: u16, pub process: Option<String>, // e.g., "node", "python" pub cwd: Option<PathBuf>, // Process working directory pub cmdline: Option<String>, // Full command line pub listening_since: Option<Duration>, } pub async fn suggest_proxy_name(info: &PortInfo) -> String { // 1. Try to infer from cwd (last path component) if let Some(cwd) = &info.cwd { if let Some(name) = cwd.file_name() { return sanitize_name(name.to_string_lossy()); } } // 2. Try to infer from process + port if let Some(proc) = &info.process { return format!("{}-{}", proc, info.port); } // 3. Fall back to port format!("svc-{}", info.port) } // For smarter naming, call LLM with context pub async fn ai_suggest_name(info: &PortInfo) -> Result<String> { let prompt = format!( "Suggest a short, memorable name for a local dev proxy:\n\ Port: {}\n\ Process: {:?}\n\ Working dir: {:?}\n\ Reply with just the name (lowercase, no spaces).", info.port, info.process, info.cwd ); // Call local LLM or API llm_complete(&prompt).await } ``` ## Implementation Plan ### Phase 1: Core Proxy 1. [ ] `ProxyConfig` struct in flow's config.rs 2. [ ] Trace ring buffer module (`src/proxy/trace.rs`) 3. [ ] Basic hyper-based proxy (`src/proxy/server.rs`) 4. [ ] Router with host/path matching (`src/proxy/router.rs`) 5. [ ] Connection pool (`src/proxy/pool.rs`) ### Phase 2: Flow Integration 1. [ ] `f proxy` subcommands in CLI 2. [ ] Supervisor integration (daemon lifecycle) 3. [ ] Hot reload on flow.toml changes ### Phase 3: AI & Polish 1. [ ] Port scanning for `f proxy add` 2. [ ] AI name suggestion 3. [ ] `f proxy trace` viewer 4. [ ] Health checks ## Dependencies ```toml # Add to Cargo.toml hyper = { version = "1", features = ["http1", "http2", "server", "client"] } hyper-util = { version = "0.1", features = ["tokio"] } crossbeam = { version = "0.8", features = ["crossbeam-queue"] } # Already have: tokio, libc, memmap2 (or use libc::mmap directly) ``` ## File Structure ``` src/ ├── proxy/ │ ├── mod.rs # Re-exports │ ├── config.rs # ProxyConfig parsing │ ├── server.rs # Hyper server + request handling │ ├── router.rs # Host/path routing │ ├── pool.rs # Connection pooling │ ├── trace.rs # mmap ring buffer │ ├── summary.rs # Agent-readable JSON summary │ └── ai.rs # Name suggestion ├── cmd/ │ └── proxy.rs # CLI commands └── ... ``` --- ## Claude Code Integration (The Key Feature) The real value: **AI sees your app's behavior while helping you code**. ### CLAUDE.md Hook Add to your project's CLAUDE.md: ```markdown ## Development Context When helping with this project, check the proxy trace summary: - File: ~/.config/flow/proxy/trace-summary.json - Command: `f proxy last` for recent request details If you see errors or slow requests, mention them proactively. ``` ### Automatic Context Injection Flow can inject trace context into Claude Code sessions: ```bash # In flow.toml [claude] context_files = [ "~/.config/flow/proxy/trace-summary.json" ] ``` Now Claude Code automatically sees: - Recent errors (can fix the code causing them) - Slow requests (can suggest optimizations) - Provider health (can suggest fallbacks) - Request patterns (can identify redundant calls) ### Example Interaction You: "The user profile page is slow" Claude (reading trace-summary.json): > Looking at the trace data, I see UserProfile makes 3 requests on mount: > 1. GET /api/user/profile (8ms) ✓ > 2. POST /v1/chat/completions (4200ms) ← this is slow > 3. GET /api/user/settings (12ms) ✓ > > The chat completion is being called on every mount. Looking at the code... > this is in useEffect without deps. Should I add caching or move it to user action? ### Real-time Error Notification When a request fails, Flow can notify Claude Code: ```rust // In proxyx, when error detected: if record.status >= 500 { // Write to a file Claude Code watches write_error_notification(&record); } ``` Claude Code sees the notification and can proactively say: > "I just saw a 500 error on POST /api/mutations - the request body had an invalid schema. Want me to fix it?" --- ## Development Workflow with Traces ### Starting Development ```bash # Terminal 1: Start proxyx (intercepts all traffic) f proxy start # Terminal 2: Start Rise services through proxy f dev # web, daemon, api all route through proxyx # Terminal 3: Claude Code # Claude reads trace-summary.json automatically ``` ### While Coding 1. **You edit code** → triggers requests 2. **proxyx records** → updates trace-summary.json 3. **Claude Code reads** → understands what happened 4. **Claude suggests** → "That request failed because..." ### Debugging Session ```bash # See what's happening right now f proxy trace -f # See last error details f proxy last --errors # See specific request with body f proxy show abc123 --body # Compare before/after f proxy diff --before "5min" --after "now" ``` ### Example: Fixing a Bug ``` You: "Login is broken" Claude: > Checking trace-summary.json... > I see POST /api/auth/login returning 401 for the last 5 requests. > The error response is: {"error": "Invalid token format"} > > Looking at your recent code changes... you modified auth.ts 3 minutes ago. > The issue is on line 42 - you're passing the token without the "Bearer " prefix. > > Here's the fix: [shows diff] ``` --- ## Summary: Why This Matters | Without proxyx | With proxyx | |----------------|-------------| | "Something is slow" | "zai provider timed out, cerebras fallback added 4s" | | "Login is broken" | "POST /api/auth returned 401, token format invalid" | | "Too many requests" | "UserProfile calls /v1/chat/completions on every mount" | | Check daemon logs manually | Claude reads trace-summary.json automatically | | Logs lost on restart | Ring buffer persists, zero-cost | | No correlation | Trace ID links all services | **The core insight**: Development is about understanding what your code does at runtime. proxyx captures this automatically and makes it available to AI agents helping you write code. ================================================ FILE: docs/read-stream-of-logs.md ================================================ # Reactive Log Stream Processing This document describes how to build a TypeScript service that watches the flow logs database for new entries and takes action on errors (e.g., sending macOS notifications). ## Architecture ``` ┌─────────────┐ POST ┌──────────────┐ writes ┌─────────────┐ │ Your Apps │ ────────────► │ f server │ ─────────────► │ flow.db │ └─────────────┘ └──────────────┘ └─────────────┘ │ │ watches ▼ ┌─────────────┐ │ Log Watcher│ │ (this doc) │ └─────────────┘ │ │ on error ▼ ┌─────────────┐ │ Actions │ │ - notify │ │ - webhook │ │ - AI fix │ └─────────────┘ ``` ## Implementation ### Option 1: Polling (Simple) Poll the database for new logs since the last check. ```typescript // log-watcher.ts import Database from "bun:sqlite"; import { exec } from "child_process"; import { homedir } from "os"; import { join } from "path"; const DB_PATH = join(homedir(), ".config/flow/flow.db"); const POLL_INTERVAL_MS = 1000; interface LogEntry { id: number; project: string; content: string; timestamp: number; log_type: string; service: string; stack: string | null; format: string; } function sendMacNotification(title: string, message: string) { const escaped = message.replace(/"/g, '\\"').substring(0, 200); exec( `osascript -e 'display notification "${escaped}" with title "${title}"'` ); } async function onError(entry: LogEntry) { console.log(`[ERROR] ${entry.project}/${entry.service}: ${entry.content}`); // Action 1: macOS notification sendMacNotification( `Error in ${entry.project}`, `${entry.service}: ${entry.content}` ); // Action 2: Call AI to analyze/fix (placeholder) // await analyzeWithAI(entry); } function watchLogs() { const db = new Database(DB_PATH, { readonly: true }); let lastId = 0; // Get the current max ID to start from const latest = db.query("SELECT MAX(id) as max_id FROM logs").get() as { max_id: number | null; }; lastId = latest?.max_id ?? 0; console.log(`Watching logs from id > ${lastId}...`); setInterval(() => { const newLogs = db .query( ` SELECT id, project, content, timestamp, log_type, service, stack, format FROM logs WHERE id > ? ORDER BY id ASC ` ) .all(lastId) as LogEntry[]; for (const log of newLogs) { lastId = log.id; if (log.log_type === "error") { onError(log); } } }, POLL_INTERVAL_MS); } watchLogs(); ``` Run with: ```bash bun log-watcher.ts ``` ### Option 2: File System Watch (More Reactive) Watch the SQLite file for changes using fs notifications. ```typescript // log-watcher-fs.ts import Database from "bun:sqlite"; import { watch } from "fs"; import { exec } from "child_process"; import { homedir } from "os"; import { join } from "path"; const DB_PATH = join(homedir(), ".config/flow/flow.db"); interface LogEntry { id: number; project: string; content: string; timestamp: number; log_type: string; service: string; stack: string | null; format: string; } function sendMacNotification(title: string, message: string) { const escaped = message.replace(/"/g, '\\"').substring(0, 200); exec( `osascript -e 'display notification "${escaped}" with title "${title}"'` ); } async function onError(entry: LogEntry) { console.log(`[ERROR] ${entry.project}/${entry.service}: ${entry.content}`); sendMacNotification( `Error in ${entry.project}`, `${entry.service}: ${entry.content}` ); } function createWatcher() { let lastId = 0; let debounceTimer: Timer | null = null; function checkNewLogs() { const db = new Database(DB_PATH, { readonly: true }); try { if (lastId === 0) { const latest = db.query("SELECT MAX(id) as max_id FROM logs").get() as { max_id: number | null; }; lastId = latest?.max_id ?? 0; console.log(`Starting from id ${lastId}`); return; } const newLogs = db .query( ` SELECT id, project, content, timestamp, log_type, service, stack, format FROM logs WHERE id > ? ORDER BY id ASC ` ) .all(lastId) as LogEntry[]; for (const log of newLogs) { lastId = log.id; if (log.log_type === "error") { onError(log); } } } finally { db.close(); } } // Initial check checkNewLogs(); // Watch for file changes watch(DB_PATH, (eventType) => { if (eventType === "change") { // Debounce rapid changes if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(checkNewLogs, 100); } }); console.log(`Watching ${DB_PATH} for changes...`); } createWatcher(); ``` ### Option 3: HTTP Streaming Endpoint (Future) Add a streaming endpoint to `f server` for real-time log delivery via SSE. ```rust // In log_server.rs (future enhancement) async fn logs_stream() -> impl IntoResponse { // Server-Sent Events stream // Clients connect and receive new logs in real-time } ``` Client would consume: ```typescript const events = new EventSource("http://127.0.0.1:9060/logs/stream"); events.onmessage = (e) => { const log = JSON.parse(e.data); if (log.type === "error") { handleError(log); } }; ``` ## Actions on Error ### macOS Notification ```typescript import { exec } from "child_process"; function notify(title: string, message: string, sound = "default") { const escaped = message.replace(/"/g, '\\"').substring(0, 200); exec( `osascript -e 'display notification "${escaped}" with title "${title}" sound name "${sound}"'` ); } ``` ### Webhook ```typescript async function sendWebhook(url: string, entry: LogEntry) { await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: `Error in ${entry.project}/${entry.service}: ${entry.content}`, entry, }), }); } ``` ### AI Analysis ```typescript async function analyzeWithAI(entry: LogEntry) { const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ANTHROPIC_API_KEY!, "anthropic-version": "2023-06-01", }, body: JSON.stringify({ model: "claude-sonnet-4-20250514", max_tokens: 1024, messages: [ { role: "user", content: `Analyze this error and suggest a fix: Project: ${entry.project} Service: ${entry.service} Error: ${entry.content} ${entry.stack ? `Stack trace:\n${entry.stack}` : ""} Provide a brief analysis and actionable fix.`, }, ], }), }); const data = await response.json(); const analysis = data.content[0].text; // Send analysis as notification or log it console.log("AI Analysis:", analysis); notify("AI Fix Suggestion", analysis.substring(0, 200)); } ``` ## Full Example: Error Monitor Service ```typescript // error-monitor.ts import Database from "bun:sqlite"; import { watch } from "fs"; import { exec } from "child_process"; import { homedir } from "os"; import { join } from "path"; const DB_PATH = join(homedir(), ".config/flow/flow.db"); interface LogEntry { id: number; project: string; content: string; timestamp: number; log_type: string; service: string; stack: string | null; format: string; } interface Config { notify: boolean; webhook?: string; aiAnalysis: boolean; projectFilter?: string[]; } const config: Config = { notify: true, aiAnalysis: false, // Enable if you have ANTHROPIC_API_KEY set // projectFilter: ['my-app'], // Only watch specific projects }; function notify(title: string, message: string) { if (!config.notify) return; const escaped = message.replace(/"/g, '\\"').substring(0, 200); exec( `osascript -e 'display notification "${escaped}" with title "${title}" sound name "Basso"'` ); } async function handleError(entry: LogEntry) { // Skip if project filter is set and doesn't match if ( config.projectFilter && !config.projectFilter.includes(entry.project) ) { return; } const timestamp = new Date(entry.timestamp).toLocaleTimeString(); console.log( `\n[${timestamp}] ERROR in ${entry.project}/${entry.service}` ); console.log(` ${entry.content}`); if (entry.stack) { console.log(` Stack: ${entry.stack.split("\n")[0]}`); } // Send notification notify(`${entry.project} error`, `${entry.service}: ${entry.content}`); // Send to webhook if configured if (config.webhook) { fetch(config.webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(entry), }).catch(console.error); } } function startWatcher() { let lastId = 0; let debounceTimer: Timer | null = null; function checkNewLogs() { const db = new Database(DB_PATH, { readonly: true }); try { if (lastId === 0) { const latest = db .query("SELECT MAX(id) as max_id FROM logs") .get() as { max_id: number | null }; lastId = latest?.max_id ?? 0; return; } const newLogs = db .query( `SELECT id, project, content, timestamp, log_type, service, stack, format FROM logs WHERE id > ? ORDER BY id ASC` ) .all(lastId) as LogEntry[]; for (const log of newLogs) { lastId = log.id; if (log.log_type === "error") { handleError(log); } } } finally { db.close(); } } checkNewLogs(); watch(DB_PATH, (eventType) => { if (eventType === "change") { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(checkNewLogs, 50); } }); console.log("Error monitor started"); console.log(`Watching: ${DB_PATH}`); console.log(`Notifications: ${config.notify ? "enabled" : "disabled"}`); if (config.projectFilter) { console.log(`Projects: ${config.projectFilter.join(", ")}`); } console.log(""); } startWatcher(); ``` Run as a background service: ```bash # Run in foreground bun error-monitor.ts # Run in background nohup bun error-monitor.ts > /tmp/error-monitor.log 2>&1 & ``` ## Testing 1. Start the log server: `f server` 2. Start the watcher: `bun error-monitor.ts` 3. Send a test error: ```bash curl -X POST http://127.0.0.1:9060/logs/ingest \ -H "Content-Type: application/json" \ -d '{"project":"test","content":"Test error","timestamp":'$(date +%s000)',"type":"error","service":"test","format":"text"}' ``` You should see the error logged and receive a macOS notification. ================================================ FILE: docs/rise-sandbox-feature-test-runbook.md ================================================ # Rise Sandbox Feature Test Runbook (Flow) Use this when you want deterministic, isolated feature checks in a VM and fast feedback for infra tuning. ## Goal - Verify a feature works in a clean sandbox. - Avoid host-machine state leaks. - Capture timings/logs so CI/CD and install paths can be optimized. ## Prereqs - macOS host. - `rise` available. - `vibe` VM binary from `~/repos/lynaghk/vibe`. `rise sandbox` expects the VM-oriented `vibe`, not the unrelated CLI binary some PATHs contain. Preflight: ```bash cd ~/repos/lynaghk/vibe cargo build --release ``` ## Canonical Sandbox Command From `~/code/rise`: ```bash rise sandbox "set -euo pipefail; <your commands>; echo SANDBOX_OK" \ --root ~/code/flow \ --expect SANDBOX_OK ``` Why this shape: - `set -euo pipefail` fails hard on the first real issue. - `--expect` gives a strict pass/fail marker. - `--root ~/code/flow` mounts the Flow repo into `/root/project`. ## Feature Test Template Replace with your feature command: ```bash rise sandbox "set -euo pipefail; cd /root/project; <feature command>; echo FEATURE_OK" \ --root ~/code/flow \ --expect FEATURE_OK ``` ## Installer/Release Verification (Flow) Use this to verify `curl -fsSL https://myflow.sh/install.sh | sh` pulls the latest release: ```bash rise sandbox "set -euo pipefail; curl -fsSL https://myflow.sh/install.sh | sh; ~/.flow/bin/f --version; echo INSTALL_OK" \ --root ~/code/flow \ --expect INSTALL_OK ``` Then verify the latest release tag is what you expect: ```bash gh release view --repo nikivdev/flow --json tagName,publishedAt ``` ## Infra Optimization Loop 1. Run the same sandbox test 3-5 times. 2. Record: - VM boot + script duration from `rise sandbox` output. - Feature command duration inside script (`time <cmd>` if needed). - Artifact install/build timing (`f --version`, compile/install steps). 3. Compare before/after infra changes: - CI runner mode (`github` vs `host` vs `blacksmith`). - Caching changes. - Installer path changes. Sandbox logs are emitted under: ```bash ~/code/flow/out/logs/sandbox-<timestamp>.log ``` ## Common Failures ### `vibe: error: unrecognized arguments: --cpus --ram ...` Cause: wrong `vibe` binary in PATH. Fix: build/use `~/repos/lynaghk/vibe/target/release/vibe` (rise resolves this first when present). ### Sandbox passes but installed version seems old Check: 1. Latest release tag: ```bash gh release view --repo nikivdev/flow --json tagName,publishedAt ``` 2. Latest release workflow status: ```bash gh run list -R nikivdev/flow --limit 5 ``` If the latest release tag points to your target commit, installer should fetch that version. ================================================ FILE: docs/rise.md ================================================ # Rise This document explains how Rise integrates with Flow and why installing Rise gives you a high-leverage workflow layer across repos. Rise itself is treated as closed/internal product code in many environments, but its operator model and command surface can still be documented and shared. ## Start Here Integration starts with one command: ```bash f install rise ``` After install: ```bash which rise rise --help ``` You now have `rise` on your PATH and can use Rise workflows from any repo. From Flow's install behavior (`f install`): - backend resolution is automatic (`registry` -> `parm` -> `flox`) - `rise` is a built-in known install target - Rise can be part of bootstrap tool install flows ## What You Get From `rise` In Practice Rise is not just one command; it is a workflow layer that adds: 1. Repo adoption overlays for external/team repositories. 2. Task detection and `flow.toml` generation/merge. 3. JJ-native branch/bookmark workflow for clean PRs. 4. Multi-platform compile/dev loops (web, Expo/mobile, Electron, COI). 5. Mobile/TestFlight build observability and debugging. 6. Schema workflows with generated TypeScript/Effect bindings. 7. Sandbox verification in VM environments. 8. AI and trace workflows that integrate with Flow and surrounding tooling. ## Core Model: Overlay, Not Pollution The defining Rise behavior is `rise adopt`. For external repos, Rise creates a JJ overlay bookmark (`rise`) above `main`: - `rise` layer contains your local workflow files (for example `flow.toml`, `.rise/`). - Team-facing `main` remains untouched. - PR branches are created from `main`, so Rise files do not leak into PRs. Typical flow: ```bash rise adopt https://github.com/org/repo cd ~/code/org/repo rise sync jj new main -m "feat: clean PR change" ``` This is the main reason Rise is valuable in mixed team environments: private operator ergonomics without contaminating shared repo history. ## Task Detection And Flow Integration During adoption, Rise detects project tasks from common sources (for example `package.json`, `Makefile`, `Cargo.toml`, `go.mod`, `pyproject.toml`, `justfile`) and generates `flow.toml`. You keep the generated baseline and extend it with your own project-specific tasks. Useful commands: ```bash rise adopt . rise adopt --force . rise flow tasks . rise sync rise list ``` Key integration point: this makes Flow task execution (`f <task>`) available even in repos that did not originally ship with Flow conventions. ## Development Loops Rise provides higher-level wrappers while preserving underlying toolchains. Common commands: ```bash rise dev rise app rise run dev --runner turbo rise setup rise verify ``` From Rise docs/repo behavior: - `rise dev` starts local dev paths with platform-aware behavior. - `rise app` is a fast app shortcut path. - `rise run` can delegate to task runners (Turbo/Flow patterns). - `rise setup` is project setup entrypoint. - `rise verify` is local verification flow. ## Mobile/TestFlight Workflows (High Value) Rise has dedicated mobile commands with structured observability: ```bash rise mobile validate rise mobile preflight rise mobile testflight rise mobile builds rise mobile logs ``` Why this matters: - `validate` catches JS bundle issues quickly. - `preflight` checks config + bundle + prebuild inspect. - `testflight` captures structured build events. - `builds`/`logs` provide post-failure visibility and faster debugging loops. This reduces the "wait 10+ minutes to discover obvious build issues" pattern in Expo/EAS flows. ## Schema Workflows Rise includes schema lifecycle commands: ```bash rise schema init rise schema status rise schema diff rise schema generate rise schema push rise schema validate ``` This supports a source-of-truth schema flow with generated app bindings (including TypeScript/Effect-oriented outputs in documented workflows). ## Sandbox Workflows Rise supports VM-backed sandbox execution and verification: ```bash rise sandbox rise sandbox verify rise verify --sandbox rise sandbox kill rise sandbox clean ``` Use this when you need stronger isolation for verification or reproduction loops. ## AI + Traces + Operational Debugging Rise docs also describe: - AI trace collection (`rise logs`) - build failure context for AI-assisted remediation (`rise work --errors` paths) - integration patterns around generated prompts and operational context For Flow users, this complements: - `f commit` review/message provider strategies that can call Rise-backed providers - task failure hooks that invoke `rise work` ## Typical Onboarding Sequence For a new machine: ```bash f doctor f auth login f install rise rise --help ``` For an external/team repo: ```bash cd ~/code/org/repo rise adopt . rise sync ``` For daily work: ```bash rise verify rise mobile preflight # if mobile repo jj new main -m "feat: ..." ``` ## When To Use Rise Use Rise when you want: - an opinionated operator layer over heterogeneous repos - clean PRs with private local tooling overlays - stronger mobile/testflight diagnostics - schema/sandbox/dev orchestration from one CLI surface Use plain Flow-only setup when: - repo already has stable native workflows and minimal onboarding cost - you do not need overlay-based separation - your team explicitly avoids JJ overlay workflows ## Relationship To Flow Flow and Rise are complementary: - Flow is the general control plane (`f tasks`, `f env`, `f commit`, deploy/sync/invariants). - Rise is the repo/workflow acceleration layer for adoption, platform compile loops, mobile observability, and overlay workflows. The practical entrypoint is still: ```bash f install rise ``` Then use `rise` where it adds leverage, while keeping Flow as your consistent command contract across projects. ## References In Rise Docs If you have access to the internal Rise repo docs, these are particularly relevant: - `docs/adopt-guide.md` - `docs/workflow-guide.md` - `docs/rise-branch-workflow.md` - `docs/build-observability.md` - `docs/schema-guide.md` - `docs/sandbox-vibe.md` - `docs/rise-mobile-compat.md` - `docs/expo-identifiers.md` ================================================ FILE: docs/rl-for-myflow-harbor.md ================================================ # RL Plan For myflow -> Harbor System Use this plan to turn the current export/prep automation into a measurable RL improvement loop for agent behavior. ## Scope Current system already has: - myflow export to Harbor snapshots (`assistant_sft.jsonl`, `train_events.jsonl`, `summary.json`) - deterministic Harbor split prep (`train/val/test/canary` + `manifest.json`) - infra timer wiring for recurring export/prepare jobs - Maple telemetry hooks for export visibility Goal: convert this into a closed loop where training updates are driven by observed failures/regressions and promoted only through hard gates. ## What RL Should Improve Primary outcomes: 1. Better action selection in real workflows (fewer wrong tool/actions). 2. Lower production regressions (canary deltas trend positive). 3. Faster convergence per run (more useful data per training cycle). 4. Higher reliability under ambiguous/long-horizon tasks. ## Phase Plan ## Phase 0: Stabilize Data Reliability (Now) 1. Enforce snapshot integrity checks in Harbor ingest. 2. Fail job if `assistant_sft.jsonl` is empty or split counts are invalid. 3. Persist run metadata keyed by snapshot timestamp and git SHA of training config. Done definition: - every snapshot has a valid manifest + non-empty train split - every training run can be traced back to one exact snapshot + config ## Phase 1: Reward Signal Contract (Next) 1. Define reward schema from `train_events.jsonl` (success, retries, rollback, human override, time-to-fix). 2. Map each signal to normalized reward components in Harbor. 3. Store per-sample reward breakdown for auditability. Done definition: - reward function is versioned (`reward_schema_version`) - each trained sample has explainable reward components ## Phase 2: Offline RL + Canary Gate (Next) 1. Train candidate adapters on latest prepared snapshot. 2. Evaluate on fixed holdout + canary split from same manifest. 3. Add strict promotion gate: holdout pass + canary pass + no action-collapse. Done definition: - promotion is blocked automatically on gate failure - gate outputs are attached to snapshot and run IDs ## Phase 3: Continuous Hard-Case Mining (Then) 1. Mine failed canary/production cases into a hardcase set. 2. Re-inject hardcases with higher sampling weight in next cycle. 3. Track “failure class recurrence” across runs. Done definition: - recurring failure classes trend downward across 3+ cycles ## Minimal Metrics To Track - `canary_reward_delta_mean` - `canary_reward_delta_ci95_low/high` - `action_error_rate` - `fallback_or_override_rate` - `time_to_resolution_p50/p95` - `hardcase_recurrence_rate` ## Runbook (Operator Loop) ```bash # 1) Export latest data from myflow to Harbor cd ~/code/myflow f harbor-export-data-maple # 2) Prepare deterministic splits cd ~/repos/laude-institute/harbor python3 scripts/prepare_myflow_dataset.py --snapshot latest # 3) Train/eval candidate in Harbor (task names TBD in harbor) # 4) Promote only if holdout + canary gates pass ``` ## Immediate Next Steps 1. Add Harbor task: `myflow-validate-snapshot` (manifest + split sanity checks). 2. Add Harbor task: `myflow-eval-canary` (fixed JSON report schema for promotion gate). 3. Add Harbor task: `myflow-mine-hardcases` (from failed canary/prod traces). 4. Add one weekly dashboard cut from Maple + Harbor manifests for trend review. ================================================ FILE: docs/rl-myflow-harbor-task-specs.md ================================================ # RL Task Specs: myflow -> Harbor Concrete task contracts for the Harbor RL loop. These map directly to scripts currently in `~/repos/laude-institute/harbor/scripts`. ## Task 1: Validate Snapshot - Script: `scripts/myflow_validate_snapshot.py` - Purpose: fail fast on broken snapshot/split artifacts before reward labeling or gating. - Command: ```bash python3 scripts/myflow_validate_snapshot.py \ --snapshot <snapshot|latest> \ --myflow-dir data/myflow \ --prepared-dir data/myflow_prepared \ --require-train-events \ --report-out data/myflow_prepared/<snapshot>/validation_report.json ``` - Inputs: - `data/myflow/<snapshot>/assistant_sft.jsonl` - `data/myflow/<snapshot>/train_events.jsonl` (required when `--require-train-events`) - `data/myflow_prepared/<snapshot>/manifest.json` - Outputs: - validation report JSON - Exit contract: - `0` = pass - non-zero = integrity failure ## Task 2: Build Reward Labels - Script: `scripts/myflow_build_reward_labels.py` - Purpose: produce versioned per-event rewards for RL training and canary gating. - Command: ```bash python3 scripts/myflow_build_reward_labels.py \ --snapshot <snapshot|latest> \ --myflow-dir data/myflow \ --out-dir data/myflow_rewards ``` - Inputs: - `data/myflow/<snapshot>/train_events.jsonl` - Outputs: - `data/myflow_rewards/<snapshot>/train_event_rewards.jsonl` - `data/myflow_rewards/<snapshot>/reward_summary.json` - Exit contract: - `0` = labels generated - non-zero = parse/IO/schema error ## Task 3: Canary Promotion Gate - Script: `scripts/myflow_eval_canary.py` - Purpose: gate promotion based on reward quality and optional baseline deltas. - Command: ```bash python3 scripts/myflow_eval_canary.py \ --candidate data/myflow_rewards/<snapshot>/train_event_rewards.jsonl \ --baseline data/myflow_rewards/<baseline_snapshot>/train_event_rewards.jsonl \ --report-out data/myflow_reports/<snapshot>/canary_gate.json \ --min-candidate-mean 0.55 \ --min-delta-mean 0.00 \ --min-delta-ci95-low -0.02 ``` - Inputs: - candidate rewards JSONL - optional baseline rewards JSONL - optional `--rollouts` for action-dominance gate - Outputs: - canary gate report JSON - Exit contract: - `0` = promotion gate pass - `1` = gate fail (expected for regressions) - non-zero other = runtime error ## Task 4: Mine Hardcases - Script: `scripts/myflow_mine_hardcases.py` - Purpose: mine regressions/low-reward canary samples and produce next-cycle seed set. - Command: ```bash python3 scripts/myflow_mine_hardcases.py \ --snapshot <snapshot|latest> \ --prepared-dir data/myflow_prepared \ --candidate-rewards data/myflow_rewards/<snapshot>/train_event_rewards.jsonl \ --baseline-rewards data/myflow_rewards/<baseline_snapshot>/train_event_rewards.jsonl \ --out-dir data/myflow_hardcases \ --top-k 100 ``` - Inputs: - prepared canary/train splits - candidate rewards - optional baseline rewards - Outputs: - `hardcases.jsonl` - `next_train_seed.jsonl` - `hardcase_summary.json` - Exit contract: - `0` = hardcases emitted - non-zero = missing inputs / parse errors ## Recommended Harbor Flow Task Names 1. `myflow-validate-snapshot` 2. `myflow-build-reward-labels` 3. `myflow-eval-canary` 4. `myflow-mine-hardcases` Use these names in Harbor task orchestration so docs/runbooks stay stable. ## Executed Verification (2026-02-18) Executed end-to-end on a deterministic fixture snapshot: 1. `prepare_myflow_dataset.py` (30 rows input) 2. `myflow_validate_snapshot.py` -> `PASS` 3. `myflow_build_reward_labels.py` -> labels generated 4. `myflow_eval_canary.py` -> `Promotion gate: PASS` 5. `myflow_mine_hardcases.py` -> hardcases + next seed generated Artifact root used during verification: - `/var/folders/.../tmp.5arfBojfhp/*` (temporary run directory) ================================================ FILE: docs/rl-signal-capture-runbook.md ================================================ # RL Signal Capture Runbook (Flow + Seq) This is the Phase 1 capture path for low-latency, high-signal RL data. ## 1) Enable low-latency local seq capture From `~/code/seq`: ```bash f rl-capture-on f agent-qa-capture-on ``` This forces local spool mode (`SEQ_CH_MODE=file`) so user-path latency is not tied to remote network writes. Or from `~/code/flow` (single command): ```bash f rl-capture-on-all ``` ## 2) Enable flow RL signal logging From `~/code/flow`: ```bash export FLOW_RL_SIGNALS=true export FLOW_RL_SIGNALS_PATH=out/logs/flow_rl_signals.jsonl export FLOW_RL_SIGNALS_SEQ_MIRROR=true export FLOW_RL_SIGNALS_SEQ_PATH=~/.config/flow/rl/seq_mem.jsonl export FLOW_RL_SIGNAL_TEXT=snippet export FLOW_RL_SIGNAL_MAX_CHARS=4000 ``` `f ai everruns ...` now emits structured runtime/tool events into the JSONL file. `f ai:*` task execution via `ai-taskd` now also emits linked router events: - `flow.router.decision.v1` - `flow.router.override.v1` (when a suggested task differs from chosen task) - `flow.router.outcome.v1` These are mirrored directly into `seq_mem.jsonl` when `FLOW_RL_SIGNALS_SEQ_MIRROR=true`. To capture override events, set suggestion context on the command that triggers `f ai:*`: ```bash export FLOW_ROUTER_SUGGESTED_TASK=ai:flow/noop export FLOW_ROUTER_OVERRIDE_REASON=manual_user_choice f ai:flow/dev-check ``` ## 3) Inspect quality in real time From `~/code/flow`: ```bash f rl-signals-tail f rl-signals-summary --last 2000 ``` From `~/code/seq`: ```bash f rl-signal-tail f rl-signal-summary ``` ## 4) What should be present - `everruns.run_started` - `everruns.runtime_event` (includes stage + duration) - `everruns.tool_call_result` (includes seq op, success/failure, error class) - `everruns.qa_pair` (prompt/response supervision pair) - `everruns.run_completed` or `everruns.run_failed` - `agent.qa.pair` in `seq_mem.jsonl` (Claude/Codex Q/A pairs from background ingest) - `flow.router.decision.v1` in `seq_mem.jsonl` - `flow.router.override.v1` in `seq_mem.jsonl` (when suggestion context is provided) - `flow.router.outcome.v1` in `seq_mem.jsonl` ## 5) Build Harbor snapshot from runtime traces From `~/code/flow`: ```bash f rl-dataset-build f rl-dataset-validate ``` Outputs: - `~/repos/laude-institute/harbor/data/flow_runtime/<timestamp>/events.jsonl` - `~/repos/laude-institute/harbor/data/flow_runtime_prepared/<timestamp>/train.jsonl` - `~/repos/laude-institute/harbor/data/flow_runtime_prepared/<timestamp>/val.jsonl` - `~/repos/laude-institute/harbor/data/flow_runtime_prepared/<timestamp>/test.jsonl` - `~/repos/laude-institute/harbor/data/flow_runtime_prepared/<timestamp>/validation_report.json` Latest rolling copies are also written under `.../flow_runtime/latest` and `.../flow_runtime_prepared/latest`. If capture is currently Q/A-only (`assistant_sft_example` rows), validation automatically relaxes event-diversity gates and still enforces row-count and basic quality checks. ## 6) Feed into Harbor training loop Keep this file as raw trajectory telemetry; downstream pipelines should join with: - myflow commit/session exports - flow anon telemetry snapshots - reward labels / canary outcomes Do not train directly on raw logs without redaction + quality filtering. ================================================ FILE: docs/run-repos.md ================================================ # Run Repos Shortcuts (`f r`, `f ri`, `f rp`, `f rip`) This workflow lets you run Flow tasks in `~/run` and `~/run/i` from anywhere, without manual `cd`. ## Standard Layout ```text ~/run/ # public run repo (has flow.toml) ~/run/i/ # internal run repo (has flow.toml) ~/run/i/linsa/ # nested internal project example ``` `f health` now ensures `~/run` and `~/run/i` directories exist. Root behavior: - This is a hard path: run repos live under `~/run`. - `RUN_ROOT` can still override the root explicitly. ## Primary Commands | Command | Meaning | |---|---| | `f r <task> [args...]` | Run task in `~/run` | | `f ri <task> [args...]` | Run task in `~/run/i` | | `f rp <project> <task> [args...]` | Run task in project under run tree | | `f rip <project> <task> [args...]` | Run task in `~/run/i/<project>` | ## Resolution Rules `f rp <project> ...` resolves in this order: 1. `~/run/<project>` 2. `~/run/i/<project>` (fallback) If both exist, Flow fails with an ambiguity error and asks for explicit path: - `f rp <project> ...` for public path - `f rp i/<project> ...` or `f rip <project> ...` for internal path ## Nested Project Support Nested `flow.toml` projects are supported. Example: ```bash f rip linsa bootstrap f rp linsa opencode-codex-login ``` Both target `~/run/i/linsa` (unless `~/run/linsa` also exists). ## Why This Is Robust - Uses explicit `f run --config <dir>/flow.toml <task>` internally. - Avoids task-lookup ambiguity when nested `flow.toml` files exist. - Blocks unsafe paths (`/absolute`, `..` traversal) for run repo/project selectors. ## Discovery and Maintenance ```bash f run-list # list all flow.toml repos/projects under ~/run (recursive) f run-sync # sync all git repos under ~/run (recursive) f run-sync i # sync only ~/run/i ``` ## Script Interface Task shortcuts are powered by: ```bash scripts/run-repos.sh ``` Direct script commands: ```bash bash ./scripts/run-repos.sh r <task> [args...] bash ./scripts/run-repos.sh ri <task> [args...] bash ./scripts/run-repos.sh rp <project> <task> [args...] bash ./scripts/run-repos.sh rip <project> <task> [args...] ``` `RUN_ROOT` can be overridden for testing: ```bash RUN_ROOT=/tmp/my-run-layout f rp linsa whoami ``` ================================================ FILE: docs/seq-agent-rpc-contract.md ================================================ # Seq Agent RPC Contract (Hard Interface) This document defines the required interface between agent runtimes (Flow/AI server) and OS-level automation. Status: **mandatory for new integrations**. If an agent needs macOS UI/app/input actions, it must call `seqd` via the Rust `seq_client` library. ## Why this is hard policy - Lowest control-plane overhead (persistent local Unix socket, no shell spawn per tool call). - Typed request/response contract with stable envelope fields. - Better observability (`request_id`, `run_id`, `tool_call_id`) across planner + OS executor. - Avoids drift from ad-hoc shell wrappers. ## Required architecture 1. Planner/agent loop runs in AI server. 2. AI server uses `seq_client` (`~/code/seq/api/rust/seq_client`) for OS actions. 3. `seq_client` sends JSON RPC v1 over Unix socket to `seqd`. 4. `seqd` executes OS ops and returns typed response envelope. Do not insert shell wrappers in the hot path for OS actions. ## Allowed and forbidden paths Allowed (required): - Rust: `seq_client::SeqClient` + `RpcRequest`. - Transport: Unix socket to `seqd` (`/tmp/seqd.sock` default). - Flow runtime bridge: `f seq-rpc ...` (internally uses native Rust socket RPC, no `seq rpc` subprocess). Forbidden for production OS-tool execution: - `bash -lc "seq ..."` inside tool loop. - `curl`/`nc` direct JSON RPC from tool loop (okay for debugging only). - Parsing human text responses as protocol. ## RPC envelope requirements Every request should include: - `op` - `request_id` - `run_id` - `tool_call_id` These IDs are required for trace joinability across: - agent run logs - tool-call logs - `seqd` metrics/traces ## Operation mapping (agent tool -> seq op) - Open app: `open_app` - Toggle app: `open_app_toggle` - Run macro: `run_macro` - Click: `click` - Right click: `right_click` - Double click: `double_click` - Move mouse: `move` - Scroll: `scroll` - Drag: `drag` - Screenshot: `screenshot` - Runtime status: `ping`, `app_state`, `perf` See canonical protocol details: `~/code/seq/docs/agent-rpc-v1.md`. ## Reliability rules - Create one `SeqClient` per worker and reuse it. - Set explicit read/write timeout (`connect_with_timeout`). - Treat `ok=false` as tool failure (surface `error` field). - Retry policy: - Safe ops (`ping`, `app_state`, `perf`, maybe `screenshot`) may retry once. - Mutating UI ops (`click`, `drag`, `open_app`, `run_macro`) must not auto-retry blindly. - Max response size guard should remain enabled. ## Latency policy Hot path target is low latency at the control plane, not guaranteed zero end-to-end UI latency. Expectations: - RPC dispatch overhead should be microseconds to sub-millisecond locally. - UI/app activation latency depends on macOS/window server and target app state. Benchmark and regressions should measure: - request send/receive time at client - `dur_us` returned by `seqd` - operation-level tail latency (p95/p99) ## Minimal integration example ```rust use seq_client::{RpcRequest, SeqClient}; use serde_json::json; use std::time::Duration; fn call_open_app() -> Result<(), Box<dyn std::error::Error>> { let client = SeqClient::connect_with_timeout("/tmp/seqd.sock", Duration::from_secs(5))?; let resp = client.call( RpcRequest::new("open_app") .with_request_id("req-42") .with_run_id("run-abc") .with_tool_call_id("tool-7") .with_args_json(json!({ "name": "Safari" })), )?; if !resp.ok { return Err(format!("seq open_app failed: {:?}", resp.error).into()); } Ok(()) } ``` ## Flow native command bridge (`f seq-rpc`) For Flow-managed agent workloads, use `f seq-rpc` as the stable operator-facing interface. It keeps protocol framing in Rust and avoids ad-hoc shell parsing of `seq` output. Examples: ```bash # Health f seq-rpc ping --request-id req-1 --run-id run-1 --tool-call-id tool-1 # Open app f seq-rpc open-app "Safari" --request-id req-2 --run-id run-1 --tool-call-id tool-2 # Raw op + JSON args f seq-rpc rpc open_app --args-json '{"name":"Google Chrome"}' --pretty ``` Default socket resolution order: 1. `--socket <path>` 2. `SEQ_SOCKET_PATH` 3. `SEQD_SOCKET` 4. `/tmp/seqd.sock` ## Kimi smoke test (seq + agent workload) ```bash AI_SERVER_URL=http://127.0.0.1:7331 \ ~/code/org/gen/new/ai/scripts/ai-task.sh \ --provider nvidia \ --model moonshotai/kimi-k2.5 \ --project-path ~/code/flow \ --max-steps 6 \ --prompt "Use bash tool once to run: f seq-rpc ping --request-id kimi-smoke --run-id run-smoke --tool-call-id tool-smoke; then summarize ok/op/dur_us." ``` ## Migration checklist 1. Replace shell-based OS tools with `seq_client`. 2. For Flow command surfaces, prefer `f seq-rpc` (native Rust path) instead of `seq rpc`. 3. Ensure all OS tool calls include `request_id`, `run_id`, `tool_call_id`. 4. Remove ad-hoc JSON parsing of CLI stdout. 5. Keep `seq rpc` / `nc` only for manual debugging and smoke tests. 6. Gate new OS tool additions on this contract. ================================================ FILE: docs/session-history-mining.md ================================================ # Session History Mining for Claude/Codex/Cursor Use this when you want an AI agent to study recent Claude/Codex/Cursor work before proposing a plan. This is optimized for: - cross-project history review - low-noise context transfer - token efficiency (only new or condensed context) ## What to Use Use Flow's cross-project session browser: ```bash f sessions ``` `f sessions` scans Claude + Codex + Cursor sessions across projects, lets you pick one, and copies context to clipboard. ## Core Commands ```bash # List sessions across all projects without interactive selection f sessions --list # Only Claude sessions f sessions --provider claude --list # Only Codex sessions f sessions --provider codex --list # Only Cursor sessions f sessions --provider cursor --list # Copy selected session context (interactive picker via fzf) f sessions --provider all # Copy only the last N exchanges f sessions --provider all --count 8 # Ignore checkpoints and copy full session f sessions --provider all --full # Produce a condensed handoff summary (requires Gemini key) f sessions --provider all --handoff ``` ## Checkpoint Behavior (Important) Default `f sessions` copies context since last consumption checkpoint for the current repo. That means repeated runs do not keep re-copying old context. Checkpoint file: ```text .ai/internal/consumed-checkpoints.json ``` Use `--full` when you explicitly want full history instead of incremental context. ## Current-Repo Deep Pull (When Needed) If you need more detail from a known session in the current repo: ```bash f ai claude list f ai codex list f ai cursor list # Copy the last 6 exchanges from a selected Claude session for this repo f ai claude context - /absolute/path/to/repo 6 f ai cursor context - /absolute/path/to/repo 6 ``` Use this after `f sessions` when you want to zoom in on one thread. ## Efficient Workflow (Recommended) 1. In the target repo where you want the plan, run: `f sessions --provider all --list` 2. Pull 2 to 4 high-signal contexts: `f sessions --provider claude --count 6` `f sessions --provider codex --count 6` `f sessions --provider cursor --count 6` 3. For stale/long sessions, prefer condensed transfer: `f sessions --provider all --handoff` 4. Paste each copied output into labeled blocks in your prompt. 5. Ask for a plan with explicit constraints and ranked execution order. ## Prompt Scaffold (Attach This) Use this format when asking an agent to mine history and propose execution: ```text I have ~$500 of Claude tokens expiring in <N> day(s) and want to use them efficiently. Goal: - study goose and propose a concrete execution plan for token usage - use ideas from recent Claude/Codex/Cursor histories - rank ideas by expected impact and execution cost Constraints: - avoid low-signal exploration - maximize useful output per token - include exact next commands I should run Session context 1: <paste from f sessions --provider claude --count 6> Session context 2: <paste from f sessions --provider codex --count 6> Session context 3 (optional handoff): <paste from f sessions --provider all --handoff> Session context 4 (optional Cursor): <paste from f sessions --provider cursor --count 6> Deliver: 1. top opportunities (ranked) 2. 48-hour execution plan 3. fallback plan if one assumption fails 4. specific commands and owners ``` ## Token-Efficiency Rules - Prefer `--count` over `--full` unless you are reconstructing full intent. - Prefer `--handoff` for large stale sessions before pasting into expensive models. - Cursor transcripts use file-modified time rather than per-message timestamps, so repeated copies may include the whole latest transcript after a new edit. - Merge duplicate context manually before sending to avoid repeated tokens. - Request ranked outputs with hard deliverables (plan, commands, owners, fallback). ## Troubleshooting - `fzf not found`: install `fzf`, or use `--list` and then run interactive once `fzf` is available. - No new context copied: expected if checkpoint is current; rerun with `--full`. - `--handoff` not working: set `GEMINI_API_KEY` or `GOOGLE_API_KEY`. ================================================ FILE: docs/session-semantic-recovery-with-seq.md ================================================ # Semantic Session Recovery with Seq (Claude/Codex) Use this when local Claude/Codex session state got wiped (for example after a machine reset), and you want to recover work by searching prior sessions semantically. This workflow uses: - Flow session commands (`f ai ...`) for exact resume behavior - Seq's zvec-backed session index for semantic retrieval + fuzzy picker ## What You Get - Fast semantic search over historical Claude/Codex Q/A pairs - Scope to the current repo path - Picker flow similar to Flow fuzzy task flows (`fzf`) - Direct resume command output: - `f ai claude resume <session-id>` - `f ai codex resume <session-id>` ## Prerequisites 1. `seq` repo exists at `~/code/seq`. 2. Agent Q/A capture has data in `~/repos/alibaba/zvec/data/agent_qa.jsonl`. 3. `fzf` installed for interactive picker. ## One-Time Setup From `~/code/seq`: ```bash f rl-capture-on f agent-qa-capture-on ``` If you need historical backfill: ```bash f agent-qa-capture-on-backfill ``` Quick status check: ```bash f agent-qa-capture-status ``` ## Primary Commands From `~/code/seq`: ```bash # Semantic search + interactive picker + auto-resume f agent-session-search "router regression around branch sync" # Open picker with no query (recent-first) f agent-session-search # Non-interactive listing f agent-session-search-list "skill sync force reload" ``` Provider filter: ```bash f agent-session-search --provider claude "deploy rollback" f agent-session-search --provider codex "trace parser failure" ``` ## Use from Any Repo If you are in another repo and want path-attached session search without changing directory: ```bash f run --config ~/code/seq/flow.toml agent-session-search --path "$(pwd)" "your query" ``` List-only variant: ```bash f run --config ~/code/seq/flow.toml agent-session-search-list --path "$(pwd)" "your query" ``` ## Recovery Playbook After Reset 1. Go to target repo: - `cd /path/to/repo` 2. Run semantic search via Seq task: - `f run --config ~/code/seq/flow.toml agent-session-search --path "$(pwd)" "<query>"` 3. Pick best session in `fzf`. 4. Flow resumes exact session ID with strict provider behavior. 5. If needed, inspect normal repo-local session list: - `f ai claude list` - `f ai codex list` ## Troubleshooting - Repo path changed (rename/move): - Run `f code move-sessions --from /old/path --to /new/path`. - This migrates Claude/Codex session paths and Seq zvec `agent_qa.jsonl` metadata so folder-scoped semantic search still matches the new path. - No results: - Run `f agent-qa-capture-once --backfill --reset-state` in `~/code/seq`. - No picker: - Install `fzf`, or use `agent-session-search-list`. - Wrong scope: - Pass explicit `--path /absolute/repo/path`. ================================================ FILE: docs/set-env-with-hive.md ================================================ # Set Env Vars with Hive This doc shows how to use `hive` to store env vars in Flow’s **local personal** env store. These values are global (not tied to a repo) and are later pulled during deploy. ## Prereqs - `hive` installed (`f deploy` in `~/code/lang/mbt/hive`) - `env-help` installed (`f deploy-help` in `~/code/lang/mbt`) - Flow local env backend (default on this machine) ## Recommended: editor-based paste (multi-line) This opens your editor (Zed if installed, else nano), then saves and closes. ```bash hive --paste env ``` Paste lines like: ```bash STREAM_SERVER_HETZNER_HOST=u533855.your-storagebox.de STREAM_SERVER_HETZNER_USER=u533855 STREAM_SERVER_HETZNER_PATH=/backups/streams STREAM_SERVER_HETZNER_PORT=23 ``` Save and close the editor to apply. ## One-liner (single line) ```bash hive env STREAM_SERVER_HETZNER_HOST=u533855.your-storagebox.de STREAM_SERVER_HETZNER_USER=u533855 STREAM_SERVER_HETZNER_PATH=/backups/streams STREAM_SERVER_HETZNER_PORT=23 ``` ## Pipe (non-interactive) ```bash cat <<'EOF' | hive --paste env STREAM_SERVER_HETZNER_HOST=u533855.your-storagebox.de STREAM_SERVER_HETZNER_USER=u533855 STREAM_SERVER_HETZNER_PATH=/backups/streams STREAM_SERVER_HETZNER_PORT=23 EOF ``` ## Verify ```bash f env list ``` This lists envs in `personal` + `production` scope (values are masked). ## Deploy using Flow env store From the repo using these envs (example: stream server): ```bash cd ~/code/lang/cpp/stream f deploy host ``` Flow writes `/opt/stream/.env` on the host using the local env store. ## Notes - Env vars are stored at: `~/.config/flow/env-local/personal/production.env` - Use `hive --paste env` whenever you need multi-line input. ================================================ FILE: docs/task-failure-hooks.md ================================================ # Task Failure Hooks Flow can run a command automatically when a task fails. This is useful for opening a tailored prompt, collecting diagnostics, or launching a helper tool. ## Overview - The hook runs after a task exits with a non-zero status. - The hook runs in the task's working directory. - The hook only runs when stdin is a TTY (no hook in non-interactive runs). - The hook is disabled when `FLOW_DISABLE_TASK_FAILURE_HOOK` is set. ## Where The Hook Is Configured You can set the hook in either place: 1. Environment variable (highest priority): ```bash export FLOW_TASK_FAILURE_HOOK='rise work --errors --diff --patch --focus --focus-app lin --target codex "fix $FLOW_TASK_NAME failure"' ``` 2. Global Flow config (generated file): - Edit `~/.config/lin/config.ts` and regenerate, or - Edit `~/.config/flow/config.ts` directly if you know it is safe to do so. Example entry in config: ```ts export default { flow: { taskFailureHook: "rise work --errors --diff --patch --focus --focus-app lin --target codex \"fix $FLOW_TASK_NAME failure\"" } } ``` ## Command Execution Details - The hook is executed with `/bin/sh -c`. - The working directory is the task's `workdir` (the repo or task `cwd`). - The hook inherits stdin/stdout/stderr from the task runner. ## Environment Variables Provided To The Hook Flow sets these environment variables when the hook runs: - `FLOW_TASK_NAME` (task name) - `FLOW_TASK_COMMAND` (command string) - `FLOW_TASK_WORKDIR` (absolute path) - `FLOW_TASK_STATUS` (exit code, or `-1` if unknown) - `FLOW_FAILURE_BUNDLE_PATH` (path to the last failure bundle) - `FLOW_TASK_OUTPUT_TAIL` (tail of task output, truncated) ## Failure Bundle Location Flow writes a JSON failure bundle to one of these locations: - `FISHX_FAILURE_PATH` if set - `FLOW_FAILURE_BUNDLE_PATH` if set - `~/.cache/flow/last-task-failure.json` (default) The resolved path is passed to hooks via `FLOW_FAILURE_BUNDLE_PATH`. ## Disabling The Hook Set the following env var: ```bash export FLOW_DISABLE_TASK_FAILURE_HOOK=1 ``` ## Rise / Zed Behavior If your hook calls `rise work`, Flow automatically appends `--no-open` and strips `--focus` / `--focus-app` unless you explicitly allow opening. This prevents Zed or other apps from launching on every failure. To allow the open behavior: ```bash export FLOW_TASK_FAILURE_HOOK_ALLOW_OPEN=1 ``` ## Example Hook ```bash export FLOW_TASK_FAILURE_HOOK='rise work --errors --diff --patch --focus --focus-app lin --target codex "fix $FLOW_TASK_NAME failure"' ``` This will write prompts to `.rise/prompts/` and focus the codex prompt without opening Zed by default. ================================================ FILE: docs/usage-analytics-rollout.md ================================================ # Flow Anonymous Usage Tracking (Zero Cost) - Implementation Checklist This is the execution plan to add opt-in anonymous usage tracking to Flow with near-zero runtime overhead. ## Goals - Default off (or unknown until prompt), explicit user opt-in. - No sensitive data (no prompts, no command values, no paths, no repo names). - Command runtime must not block on network. - Ingest through `base` trace API and store in a separate ClickHouse instance. ## Data Contract (anonymous only) Event kind: `flow.command` Allowed fields: - `install_id` (random UUID, local) - `command_path` (e.g. `commit`, `skills.sync`, `setup.deploy`) - `success` (`true/false`) - `exit_code` (integer or null) - `duration_ms` (integer) - `flags_used` (flag names only; e.g. `["sync","context"]`) - `flow_version` - `os`, `arch` - `interactive` (`true/false`) - `ci` (`true/false`) - `project_fingerprint` (optional HMAC; never raw path/remote) - `at` timestamp Forbidden fields: - prompts, command strings, args values, paths, repo URL/name, output. ## Patch Order ### Phase 1: Local capture + opt-in state (Flow only) 1. Add `src/usage.rs` - `UsageConfigState` (enabled/disabled/unknown, install_id, secret, last_prompt_at). - local queue file: `~/.config/flow/usage-queue.jsonl`. - append-only write API: `record_command_event(...)`. - sanitize and normalize command path + flag names. 2. Add config support in `src/config.rs` - `[analytics]` config: - `enabled` (`true/false` optional) - `endpoint` (default `http://127.0.0.1:7331/v1/trace`) - `sample_rate` (default `1.0`) 3. Hook command lifecycle in `src/main.rs` - capture start timestamp before dispatch. - on return/error, emit one event through `usage::record_command_event`. - never fail command if analytics fails. 4. Add command group in `src/cli.rs` and handler in new `src/analytics.rs` - `f analytics status` - `f analytics enable` - `f analytics disable` - `f analytics export` - `f analytics purge` 5. Wire module exports in `src/lib.rs` and command dispatch in `src/main.rs`. Validation: ```bash cd ~/code/flow cargo check cargo run --bin f -- analytics status ``` ### Phase 2: Opt-in UX 1. In `src/main.rs`, after first successful interactive command: - if state is `unknown` and non-CI, prompt once: - "Enable anonymous usage tracking to improve Flow? [y/N/later]" 2. Persist response to `~/.config/flow/analytics.toml`. 3. Add env overrides: - `FLOW_ANALYTICS_FORCE=1` (self-test) - `FLOW_ANALYTICS_DISABLE=1` (hard off) Validation: ```bash FLOW_ANALYTICS_FORCE=1 cargo run --bin f -- tasks cargo run --bin f -- analytics status ``` ### Phase 3: Async uploader (still in Flow) 1. In `src/usage.rs` add `flush_queue_async()` - background thread - small batches (50-200) - short HTTP timeout (<=500ms) - retries with backoff 2. Upload target defaults to base trace endpoint: - `http://127.0.0.1:7331/v1/trace` 3. Add spool safety: - max queue bytes (e.g. 10MB), oldest-drop policy. Validation: ```bash f analytics status # run a few commands f tasks f skills list # then flush f analytics export ``` ### Phase 4: Base ingest + dedicated ClickHouse Implement using `base` doc `docs/flow-usage-tracking.md` (added in parallel). ### Phase 5: Read path and dashboards 1. Extend `seqch` in `~/code/org/linsa/base/crates/seqch-cli/src/main.rs` - new top-level area: `flow` - commands: - `seqch flow commands --hours 24` - `seqch flow flags --hours 24` - `seqch flow failures --hours 24` 2. Add starter SQL dashboards: - command usage over time - adoption funnel (`unknown -> enabled`) - failures by command ## Self-Test Rollout 1. Enable only for yourself: - `FLOW_ANALYTICS_FORCE=1` 2. Verify no sensitive fields in payload samples (`f analytics export`). 3. Verify ingestion into separate CH instance. 4. After 3-7 days, turn prompt on for all users (still opt-in). ## Acceptance Criteria - P50 added runtime overhead per command < 1ms local. - Command success path unaffected by network or ingest failures. - No sensitive strings in stored events (spot-check samples). - Able to answer: - top-used commands - least-used commands - failure hotspots by command path. ================================================ FILE: docs/use-flow-to-write-software-better.md ================================================ # Use Flow To Write Software Better This is a practical, opinionated guide for using Flow as the control plane for software delivery, optimized for Claude Code and Codex. The goal is simple: tighter feedback loops, fewer regressions, less context loss, and consistent quality gates. --- ## 1. Core idea: one operating loop Do not treat Flow as just a task runner. Use it as the enforced loop: 1. Start with project context and reusable skills. 2. Implement in Claude/Codex with task-native commands. 3. Run the smallest meaningful tests first. 4. Capture traces/logs when behavior is unclear. 5. Commit through `f commit` with quality/testing/skill gates. 6. Ship through Flow tasks, not ad hoc commands. If you do this consistently, team behavior becomes predictable and AI sessions become reliable. --- ## 2. Machine baseline (once per machine) Run these first: ```bash f doctor f auth login f latest ``` What this gives you: - verified shell and toolchain integration - authenticated Flow AI and storage access - latest Flow binary with current command behavior If you use fish integration heavily: ```bash f shell-init ``` --- ## 3. Project baseline (once per repo) From the repository root: ```bash f info f tasks list f setup ``` If project is not Flow-managed yet: ```bash f init ``` Then immediately add these foundations to `flow.toml`: - `[skills]` and `[skills.codex]` - `[commit.testing]` - `[commit.quality]` - `[commit.skill_gate]` - core tasks (`test`, `test-related`, build, dev, deploy/ship) --- ## 4. Reference `flow.toml` pattern (AI-first, quality-enforced) Use this as a starting profile and adjust per repo: ```toml version = 1 [project] name = "your-project" [skills] sync_tasks = true install = ["quality-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false [[tasks]] name = "test" command = "<your test command>" description = "Run project tests" [[tasks]] name = "test-related" command = "<script that runs likely related tests>" description = "Run smallest related tests for changed files" [commit] review_instructions_file = ".ai/commit-review-instructions.md" [commit.testing] mode = "block" # off | warn | block runner = "bun" # Bun-first local gate require_related_tests = true ai_scratch_test_dir = ".ai/test" # optional gitignored AI scratch tests run_ai_scratch_tests = true # run scratch tests when no related tracked tests allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 30 [commit.quality] mode = "block" require_docs = true require_tests = true auto_generate_docs = true doc_level = "basic" [commit.skill_gate] mode = "block" required = ["quality-feature-delivery"] ``` Why this matters: - `sync_tasks` + Codex skill generation makes tasks visible as skills. - blocked commit gates make quality non-optional. - related-test enforcement keeps the loop fast and relevant. --- ## 5. Daily development loop (the part that compounds) ### 5.1 Start every session from repo root ```bash cd <repo> f tasks list ``` Then choose one clear objective and one validation command before coding. ### 5.2 Drive execution through tasks Prefer: ```bash f dev f test-related f logs <task> ``` Avoid direct, inconsistent commands when equivalent Flow tasks exist. ### 5.3 Use Claude/Codex with explicit constraints Your prompt should include: - objective - files or subsystem boundaries - required tests - expected output shape - “commit through `f commit` without skip flags” Example prompt frame: ```text Implement X in Y files. Run f test-related-main first, then broader tests if needed. Update .ai/features for user-visible changes. Commit using f commit with no skip flags. ``` ### 5.4 Keep loop tight before broad Order of validation: 1. related tests (`f test-related` / branch-based variant) 2. subsystem suite 3. full suite only if risk justifies ### 5.5 Commit only through `f commit` ```bash f commit ``` This centralizes: - AI review - test/doc quality checks - feature documentation updates - sync/audit metadata Do not bypass with `--skip-quality` or `--skip-tests` unless explicitly intentional. --- ## 6. Features-as-knowledge (`.ai/features`) Treat `.ai/features/*.md` as the source of truth for what exists. Each user-visible feature should map to: - purpose/description - source files - test files - coverage status - last verified commit Why this is high leverage: - new AI sessions start with real project capabilities - stale feature docs are detectable at commit time - dashboard/reporting can track drift and coverage --- ## 7. Skills as enforced behavior (not optional tips) Use local skills for repo-specific “how we build here”. Recommended minimum skill set: 1. quality feature delivery (tests + docs + commit gates) 2. environment/secret usage (`f env` only) 3. release/ship protocol 4. tracing/diagnostics protocol Then enforce with: ```toml [commit.skill_gate] mode = "block" required = ["quality-feature-delivery"] ``` This is how you convert good intentions into default behavior. --- ## 8. Testing strategy for speed and confidence ### 8.1 Two test lanes - lane A: very fast related tests for development iterations - lane B: broader suite for pre-ship confidence ### 8.2 Make lane A deterministic Use a script (like `.ai/scripts/test-related.ts`) that: - maps changed source files to candidate tests - supports `--base origin/main --head HEAD` - can list commands without running (`--list`) - runs the smallest useful subset first ### 8.3 Add preflight guards for expensive runners If your runner fails due environment prerequisites (toolchain/vendor issues), add a preflight task: - `f <runner>-ready` - optional auto-repair task `f <runner>-fix` This avoids burning minutes before obvious infra failures. --- ## 9. Logging and tracing loop When behavior is unclear, switch from “guess and patch” to “observe and patch”: 1. run the target task via Flow 2. inspect `f logs <task>` 3. collect traces (`f trace` / project-specific trace tasks) 4. summarize signal before changing code The best pattern is “capture once, reason once, patch once.” --- ## 10. Environment and secrets discipline Use `f env` as the single path for secrets and runtime env management: ```bash f env setup f env set KEY=value f env pull f env run <command> ``` Avoid ad hoc `.env` drift across machines. --- ## 11. Shipping loop (release confidence) For deployment or mobile shipping flows, define one confidence task that runs before release: - health checks - trace ingestion checks - critical smoke test - related tests for release-impact files Then make ship task depend on that confidence task. Example: ```text f mobile-confidence -> f mobile-ship ``` Result: broken pipelines fail before expensive release steps. --- ## 12. Existing project onboarding (high-value sequence) When adding Flow to an existing repo, use this order: 1. add `flow.toml` with core tasks 2. add env management (`[storage]` + `f env` flow) 3. add related-test task/script 4. add commit testing + quality + skill gates in warn mode 5. validate for 2-3 days 6. flip to block mode 7. add `.ai/features` for top capabilities This avoids destabilizing the team while still moving to enforcement. --- ## 13. Prompt templates that work better ### Implementation prompt ```text Implement <feature> in <scope>. Use Flow tasks only (no ad hoc commands when task exists). Run related tests first, then broaden if risk warrants. Update .ai/features for user-visible behavior changes. Commit with f commit (no skip flags). ``` ### Debugging prompt ```text Do not patch yet. Collect logs/traces via Flow tasks and summarize likely root causes. Propose smallest validating experiment. After confirmation, implement fix + related tests + feature doc update. Commit via f commit. ``` ### Refactor prompt ```text Refactor <module> without behavior changes. Keep public API stable. Run focused tests proving no regression. Document any non-obvious migration risks. Commit via f commit. ``` --- ## 14. Anti-patterns to avoid 1. Running direct commands repeatedly when Flow tasks exist. 2. Treating tests as optional before `f commit`. 3. Using skip flags routinely. 4. Writing prompts without required validation commands. 5. Keeping feature docs as manual, stale notes. 6. Debugging by repeated blind edits instead of trace/log loop. --- ## 15. Operational checklists ### Start-of-day checklist 1. `f latest` (if Flow changed frequently) 2. `f tasks list` 3. `f ai` / `f codex` / `f claude` resume context 4. confirm one objective + one validation command ### Pre-commit checklist 1. related tests pass 2. feature docs updated (`.ai/features`) 3. no quality gate bypass intended 4. `f commit` ### Pre-ship checklist 1. confidence task passes 2. relevant traces/logs clean 3. release task run through Flow --- ## 16. Maturity model (how teams level up) ### Level 1: Convenience - tasks run through `f` - basic env usage ### Level 2: Consistency - related test task - shared review instructions - reusable skills ### Level 3: Enforcement - blocked testing/quality gates - blocked skill gate - `.ai/features` as living capability map ### Level 4: Observability-driven - preflight + confidence tasks - trace-first debugging - structured release checks Aim to reach Level 3 quickly, then Level 4 where release speed and reliability both improve. --- ## 17. Practical defaults for Codex/Claude-heavy teams Use these defaults unless you have a reason not to: - `commit.testing.mode = "block"` - `commit.quality.mode = "block"` - `commit.skill_gate.mode = "block"` - `skills.sync_tasks = true` - `skills.codex.generate_openai_yaml = true` - `skills.codex.force_reload_after_sync = true` - branch-diff related tests (`--base origin/main --head HEAD`) This gives the highest consistency with the least manual memory burden. --- ## 18. Bottom line Flow works best when it is the enforced operating system for development, not an optional helper. If you route implementation, testing, docs, commit review, and shipping through Flow, you get: - faster iteration - lower regression rates - shared project memory for humans and AI - auditable delivery quality That is the path to writing software better, repeatedly. ================================================ FILE: docs/vendor-code-intelligence.md ================================================ # Vendor Code Intelligence (Typesense) This document defines the crates-focused equivalent of `opensrc` for Flow vendoring. ## Goal Keep Cargo as resolver/build authority while adding very fast local search across: - first-party code (`src`, `crates`, `scripts`, `docs`, `tests`), - vendored crates (`lib/vendor/*`), - source metadata (`lib/vendor-manifest/*.toml` + `vendor.lock.toml`). This gives AI and humans a fast map of "what code do we own right now?" without remote lookups. ## Why This Exists Vendoring gives full control, but it increases local code volume. Without a fast index, trim/rewrite/sync loops become slower. Typesense indexing solves that by keeping an always-queryable local code/search layer. ## Commands Start local Typesense (shared launcher in `~/code/infra/base`): ```bash f vendor-typesense-setup # one-time install in flox if needed f vendor-typesense-up f vendor-typesense-status ``` Build index: ```bash f vendor-code-index ``` Search code chunks: ```bash f vendor-code-search "Router" f vendor-code-search "serde_json" --scope vendor --crate reqwest f vendor-code-search "spawn" --scope firstparty --lang rs ``` Search source inventory: ```bash f vendor-code-search-sources "ratatui" f vendor-code-search-sources "github.com" --limit 50 ``` Inspect raw inventory: ```bash f vendor-code-sources ``` ## Data Model The index script (`scripts/vendor/typesense_code_index.py`) writes: - `.vendor/typesense/sources.json`: - opensrc-style local source inventory for vendored + first-party scopes. - Typesense `<prefix>_sources` collection: - per source (crate/repo) metadata: name, version, materialized path, upstream, checksum. - Typesense `<prefix>_chunks` collection: - chunked code text + path + scope + crate + symbols + line ranges. Default prefix is `flow_code`, so the default collections are: - `flow_code_sources` - `flow_code_chunks` ## How It Stays Aligned With Upstream `vendor.lock.toml` is the canonical vendored crate set. `lib/vendor-manifest/*.toml` is the per-crate provenance and sync state. The index script reads both, so every `inhouse/sync/hydrate` cycle can be followed by: ```bash f vendor-code-index ``` This keeps search aligned with the exact pinned state currently compiled by Cargo. ## Operational Loop 1. Sync or inhouse crates (`vendor-control.sh`, `scripts/vendor/sync-*`). 2. Re-index (`f vendor-code-index`). 3. Search for dead APIs/deps/macros and trim targets (`f vendor-code-search ...`). 4. Validate (`cargo check`, vendoring verify gates). 5. Commit source churn in `flow-vendor`, pin updates in `flow`. ## Notes - The indexer is local-first and does not replace Cargo metadata. - Use `--dry-run` for large experiments before writing collections. - Use `--max-files` for quick smoke indexing in CI/debug runs. ================================================ FILE: docs/vendor-nix-inspiration.md ================================================ # Nix Ideas In Cargo-First Vendoring This vendoring model does not replace Cargo with Nix, but it borrows core Nix ideas to get reproducibility, control, and fast iteration. ## What We Borrow ### 1. Pinned, immutable inputs - `vendor.lock.toml` pins vendored source by exact commit. - `Cargo.lock` pins resolved crate versions. - CI/local hydrate from pinned state, not floating latest. Nix analogy: flake/lock pinning exact inputs. ### 2. Content/provenance tracking - `lib/vendor-manifest/<crate>.toml` records checksums and upstream metadata. - `verify --strict-provenance` enforces provenance completeness. Nix analogy: content-addressed trust and auditable input provenance. ### 3. Declarative materialization - `scripts/vendor/vendor-repo.sh hydrate` materializes `lib/vendor/*` from lock state. - Build uses explicit path patches in `Cargo.toml`. Nix analogy: declarative store realization from a locked graph. ### 4. Transactional updates and rollback safety - `vendor-control.sh inhouse` snapshots and rolls back on failure. - Vendor pin can be moved back to a known good commit. Nix analogy: atomic generation switch + rollback. ### 5. Source ownership as a separate store - source churn lives in `flow-vendor`, - product wiring and pins live in `flow`. Nix analogy: separate immutable store objects from top-level project logic. ### 6. Minimize closure size - remove unused features/deps/macros from vendored crates, - track duplicate versions and offender crates (`cargo tree -d`, `offenders.sh`). Nix analogy: reducing closure size to speed builds and improve iteration. ## Why This Matters For Our Goals - Faster compile/iteration: smaller dependency surface, less macro/dependency overhead. - Full control: direct edits in vendored crates when needed. - Reliable upstream sync: scripted update loop with lock pinning and provenance checks. - Reproducible builds: same vendored commit + same lockfile => same source graph. ## Practical Loop ```bash ~/code/rise/scripts/vendor-control.sh sync --project ~/code/flow -- --important --dry-run ~/code/rise/scripts/vendor-control.sh sync --project ~/code/flow -- --important ~/code/rise/scripts/vendor-control.sh verify --project ~/code/flow --strict-provenance scripts/vendor/vendor-repo.sh hydrate cargo check -q ``` This gives a Nix-like operational discipline while preserving Cargo ecosystem behavior. ================================================ FILE: docs/vendor-optimization-loop.md ================================================ # Vendor Optimization Loop This is the practical loop for aggressively optimizing dependencies in `flow` while keeping Cargo correctness and upstream sync reliability. ## Goals - find and fix vendoring rough edges early, - rank high-impact dependency offenders, - track compile-iteration speed improvements over time. ## Commands ```bash f update-deps --dry-run f vendor-trims f vendor-rough-audit f vendor-offenders f vendor-bench-iter -- --mode incremental --samples 3 ``` One-command loop: ```bash f vendor-optimize-loop ``` Strict mode (warnings fail): ```bash f vendor-optimize-loop -- --strict ``` ## What Each Tool Checks `vendor-rough-audit` (`scripts/vendor/rough_edges_audit.py`) checks: - lock/manifests/materialized crate path consistency, - Cargo patch wiring vs `vendor.lock.toml`, - vendored crate resolution in `Cargo.lock` (no registry source), - provenance fields in manifests (`history_head`, `upstream_repository`), - stale code index detection (`.vendor/typesense/sources.json` freshness), - extra drift artifacts (`lib/vendor/*` or patch entries not in lock). - warning-hygiene regressions in vendored crates that would reintroduce noisy release-build warnings. `vendor-offenders` (`scripts/vendor/offenders.sh`) shows: - direct dependencies ranked by transitive tree size, - duplicate version pressure (`cargo tree -d`), - proc-macro footprint. `vendor-bench-iter` (`scripts/vendor/bench_iteration.py`) provides: - repeated timing samples for a compile command (default `cargo check -q`), - rolling comparison against prior runs from `out/vendor/iteration_bench.jsonl`, - optional fail threshold for gating regressions. ## Output Artifacts - `out/vendor/rough_edges_audit.txt` - `out/vendor/offenders_latest.txt` - `out/vendor/iteration_bench.jsonl` These files make optimization work reviewable and repeatable across sessions. ## Suggested Weekly Cadence 1. `f vendor-optimize-loop -- --strict --samples 2` 2. Pick top 1-2 offender crates. 3. Apply trim/rewrite changes. 4. Re-run loop. 5. Confirm no new rough-edge findings. 6. Confirm compile iteration trend improves or stays flat. 7. Confirm upstream sync remains clean (`scripts/vendor/sync-all.sh --important --dry-run`). ================================================ FILE: flow.py ================================================ #!/usr/bin/env python3 """ Flow CLI - Demonstrate swarms in action Usage: flow single "Your question here" flow sequential "Research topic" flow concurrent "Analysis task" flow hierarchical "Complex project" flow rearrange "Creative task" flow chat "Discussion topic" flow auto "Task description" """ import argparse import sys from rich.console import Console from rich.panel import Panel from rich.markdown import Markdown console = Console() def demo_single(task: str, model: str = "gpt-4o-mini"): """Run a single agent on a task.""" from swarms import Agent console.print(Panel(f"[bold cyan]Single Agent Demo[/bold cyan]\nTask: {task}")) agent = Agent( agent_name="Assistant", model_name=model, max_loops=1, streaming=True, ) console.print("\n[yellow]Agent thinking...[/yellow]\n") response = agent.run(task) console.print(Panel(Markdown(response), title="[green]Response[/green]")) def demo_sequential(task: str, model: str = "gpt-4o-mini"): """Run a sequential workflow: Researcher -> Analyst -> Writer.""" from swarms import Agent, SequentialWorkflow console.print(Panel(f"[bold cyan]Sequential Workflow Demo[/bold cyan]\n" f"Pipeline: Researcher -> Analyst -> Writer\n" f"Task: {task}")) researcher = Agent( agent_name="Researcher", system_prompt="You are a thorough researcher. Investigate the topic and provide detailed findings with sources and data.", model_name=model, max_loops=1, ) analyst = Agent( agent_name="Analyst", system_prompt="You are an analytical expert. Take the research provided and identify key patterns, insights, and implications.", model_name=model, max_loops=1, ) writer = Agent( agent_name="Writer", system_prompt="You are a skilled writer. Take the analysis and create a clear, engaging summary with actionable conclusions.", model_name=model, max_loops=1, ) workflow = SequentialWorkflow(agents=[researcher, analyst, writer]) console.print("\n[yellow]Running pipeline...[/yellow]\n") result = workflow.run(task) console.print(Panel(Markdown(str(result)), title="[green]Final Output[/green]")) def demo_concurrent(task: str, model: str = "gpt-4o-mini"): """Run agents concurrently with different perspectives.""" from swarms import Agent, ConcurrentWorkflow console.print(Panel(f"[bold cyan]Concurrent Workflow Demo[/bold cyan]\n" f"Running 3 agents in parallel with different perspectives\n" f"Task: {task}")) optimist = Agent( agent_name="Optimist", system_prompt="You see opportunities and positive outcomes. Analyze from an optimistic perspective, highlighting benefits and potential.", model_name=model, max_loops=1, ) critic = Agent( agent_name="Critic", system_prompt="You identify risks and challenges. Analyze from a critical perspective, highlighting potential problems and concerns.", model_name=model, max_loops=1, ) pragmatist = Agent( agent_name="Pragmatist", system_prompt="You focus on practical implementation. Analyze from a pragmatic perspective, highlighting actionable steps and trade-offs.", model_name=model, max_loops=1, ) workflow = ConcurrentWorkflow(agents=[optimist, critic, pragmatist]) console.print("\n[yellow]Running agents in parallel...[/yellow]\n") results = workflow.run(task) for agent_name, output in results.items(): console.print(Panel(Markdown(str(output)), title=f"[green]{agent_name}[/green]")) def demo_hierarchical(task: str, model: str = "gpt-4o-mini"): """Run a hierarchical swarm with a director and workers.""" from swarms import Agent, HierarchicalSwarm console.print(Panel(f"[bold cyan]Hierarchical Swarm Demo[/bold cyan]\n" f"Director assigns tasks to specialized workers\n" f"Task: {task}")) planner = Agent( agent_name="Planner", system_prompt="You create detailed project plans and break down complex tasks into actionable steps.", model_name=model, max_loops=1, ) executor = Agent( agent_name="Executor", system_prompt="You implement plans and execute tasks efficiently, providing concrete outputs.", model_name=model, max_loops=1, ) reviewer = Agent( agent_name="Reviewer", system_prompt="You review work for quality, completeness, and suggest improvements.", model_name=model, max_loops=1, ) swarm = HierarchicalSwarm( name="Project-Team", description="A team that plans, executes, and reviews work", agents=[planner, executor, reviewer], max_loops=1, ) console.print("\n[yellow]Swarm working...[/yellow]\n") result = swarm.run(task) console.print(Panel(Markdown(str(result)), title="[green]Team Output[/green]")) def demo_rearrange(task: str, model: str = "gpt-4o-mini"): """Run agent rearrange with custom flow patterns.""" from swarms import Agent, AgentRearrange console.print(Panel(f"[bold cyan]Agent Rearrange Demo[/bold cyan]\n" f"Flow: idea -> designer, developer -> integrator\n" f"Task: {task}")) idea = Agent( agent_name="idea", system_prompt="You generate creative ideas and concepts. Brainstorm possibilities.", model_name=model, max_loops=1, ) designer = Agent( agent_name="designer", system_prompt="You design user experiences and visual concepts based on ideas provided.", model_name=model, max_loops=1, ) developer = Agent( agent_name="developer", system_prompt="You think about technical implementation and architecture based on ideas provided.", model_name=model, max_loops=1, ) integrator = Agent( agent_name="integrator", system_prompt="You synthesize design and development perspectives into a cohesive plan.", model_name=model, max_loops=1, ) # idea sends to both designer and developer, both send to integrator flow = "idea -> designer, developer -> integrator" rearrange = AgentRearrange( agents=[idea, designer, developer, integrator], flow=flow, ) console.print("\n[yellow]Agents coordinating...[/yellow]\n") result = rearrange.run(task) console.print(Panel(Markdown(str(result)), title="[green]Integrated Output[/green]")) def demo_chat(topic: str, model: str = "gpt-4o-mini", rounds: int = 3): """Run a group chat discussion.""" from swarms import Agent, GroupChat console.print(Panel(f"[bold cyan]Group Chat Demo[/bold cyan]\n" f"3 experts discussing for {rounds} rounds\n" f"Topic: {topic}")) scientist = Agent( agent_name="Scientist", system_prompt="You are a scientist who values evidence and empirical data. Contribute scientific perspectives to discussions.", model_name=model, max_loops=1, ) philosopher = Agent( agent_name="Philosopher", system_prompt="You are a philosopher who explores ethical and conceptual dimensions. Contribute philosophical perspectives.", model_name=model, max_loops=1, ) engineer = Agent( agent_name="Engineer", system_prompt="You are an engineer focused on practical solutions. Contribute engineering perspectives.", model_name=model, max_loops=1, ) chat = GroupChat( name="Expert-Panel", description="A panel of experts discussing complex topics", agents=[scientist, philosopher, engineer], max_loops=rounds, ) console.print("\n[yellow]Discussion starting...[/yellow]\n") result = chat.run(f"Let's discuss: {topic}") console.print(Panel(Markdown(str(result)), title="[green]Discussion Summary[/green]")) def demo_auto(task: str, model: str = "gpt-4o-mini"): """Auto-generate a swarm for a task.""" from swarms.structs.auto_swarm_builder import AutoSwarmBuilder import json console.print(Panel(f"[bold cyan]Auto Swarm Builder Demo[/bold cyan]\n" f"Automatically generates specialized agents\n" f"Task: {task}")) swarm = AutoSwarmBuilder( name="Auto-Generated-Swarm", description="Automatically built swarm for the task", verbose=True, max_loops=1, model_name=model, ) console.print("\n[yellow]Building and running swarm...[/yellow]\n") result = swarm.run(task=task) if isinstance(result, dict): console.print(Panel(json.dumps(result, indent=2), title="[green]Swarm Output[/green]")) else: console.print(Panel(Markdown(str(result)), title="[green]Swarm Output[/green]")) def main(): parser = argparse.ArgumentParser( description="Flow CLI - Demonstrate swarms in action", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: flow single "What is quantum computing?" flow sequential "Research the future of renewable energy" flow concurrent "Analyze the pros and cons of remote work" flow hierarchical "Plan a mobile app launch strategy" flow rearrange "Design a new productivity tool" flow chat "The impact of AI on society" flow auto "Create a team to analyze cryptocurrency trends" """ ) parser.add_argument( "--model", "-m", default="gpt-4o-mini", help="Model to use (default: gpt-4o-mini)" ) subparsers = parser.add_subparsers(dest="command", help="Demo type") # Single agent single_parser = subparsers.add_parser("single", help="Single agent demo") single_parser.add_argument("task", help="Task for the agent") # Sequential workflow seq_parser = subparsers.add_parser("sequential", help="Sequential workflow (Researcher -> Analyst -> Writer)") seq_parser.add_argument("task", help="Topic to research and write about") # Concurrent workflow conc_parser = subparsers.add_parser("concurrent", help="Concurrent agents (Optimist, Critic, Pragmatist)") conc_parser.add_argument("task", help="Task to analyze from multiple perspectives") # Hierarchical swarm hier_parser = subparsers.add_parser("hierarchical", help="Hierarchical swarm (Director with workers)") hier_parser.add_argument("task", help="Complex project to coordinate") # Agent rearrange rear_parser = subparsers.add_parser("rearrange", help="Agent rearrange with custom flow") rear_parser.add_argument("task", help="Creative task for the flow") # Group chat chat_parser = subparsers.add_parser("chat", help="Group chat discussion") chat_parser.add_argument("topic", help="Topic to discuss") chat_parser.add_argument("--rounds", "-r", type=int, default=3, help="Discussion rounds (default: 3)") # Auto swarm builder auto_parser = subparsers.add_parser("auto", help="Auto-generate a swarm") auto_parser.add_argument("task", help="Task description for auto-generated swarm") args = parser.parse_args() if not args.command: parser.print_help() sys.exit(0) console.print(f"\n[dim]Using model: {args.model}[/dim]\n") try: if args.command == "single": demo_single(args.task, args.model) elif args.command == "sequential": demo_sequential(args.task, args.model) elif args.command == "concurrent": demo_concurrent(args.task, args.model) elif args.command == "hierarchical": demo_hierarchical(args.task, args.model) elif args.command == "rearrange": demo_rearrange(args.task, args.model) elif args.command == "chat": demo_chat(args.topic, args.model, args.rounds) elif args.command == "auto": demo_auto(args.task, args.model) except KeyboardInterrupt: console.print("\n[red]Interrupted[/red]") sys.exit(1) except Exception as e: console.print(f"\n[red]Error: {e}[/red]") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: flow.toml ================================================ version = 1 name = "flow" [agents] review-agent = "nikivdev:review-agent" [flow] primary_task = "deploy" deploy_task = "deploy" release_task = "release" [options] # Enable gitedit.dev mirroring for commits gitedit_mirror = true # Enable gitedit.dev mirroring for commitWithCheck commit_with_check_gitedit_mirror = true # Larger default review window for big diffs during `f commit`. commit_with_check_timeout_secs = 300 gitedit_url = "https://gitedit.dev" commit_with_check_review_url = "http://100.114.156.47:3000/review" # Enable myflow.sh mirroring for commits myflow_mirror = true [commit] queue = true queue_on_issues = true [jj] default_branch = "main" remote = "origin" auto_track = true review_prefix = "review" [release] # Default release provider for `f release`. default = "registry" # Use calendar versioning for registry releases (YYYY.M.D). versioning = "calver" # Set these to your release host values. domain = "flow.yourdomain.com" base_url = "https://flow.yourdomain.com" root = "/var/www/flow" caddyfile = "/etc/caddy/Caddyfile" readme = "readme.md" [release.registry] url = "https://myflow.sh" package = "flow" bins = ["f", "lin"] default_bin = "f" token_env = "FLOW_REGISTRY_TOKEN" latest = true [flox] [flox.install] cargo.pkg-path = "cargo" eza.pkg-path = "eza" [[tasks]] name = "setup" command = "cargo check" description = "Compile the workspace to make sure the toolchain works" activate_on_cd_to_root = true [[tasks]] name = "test" command = "cargo test" description = "Run the full test suite for a basic sanity check" [[tasks]] name = "which-cargo" command = "which cargo && cargo --version" description = "Confirm cargo is coming from the managed toolchain" dependencies = ["cargo"] [[tasks]] name = "test-deps" command = "tsx tests/deps.ts" description = "Run the e2e managed-deps test script" [[tasks]] name = "dev-hub" command = "cargo run -- hub start" description = "Ensure the hub daemon is running locally" [[tasks]] name = "generate-help-full-json" command = "python3 ./scripts/generate_help_full_json.py" description = "Regenerate the embedded --help-full JSON cache." [[tasks]] name = "deps-check" command = "python3 ./scripts/deps_check.py $@" description = "Check vendored and workspace Cargo deps against the latest upstream releases." [[tasks]] name = "deploy-cli-release" command = "FLOW_PROFILE=release ./scripts/deploy.sh" description = "Build the CLI/daemon with --release and copy the binary to ~/.local/bin/flow" [[tasks]] name = "deploy" command = "FLOW_PROFILE=release ./scripts/deploy.sh" description = "Build the CLI/daemon with --release and copy the binary to ~/.local/bin/flow" [[tasks]] name = "deploy-traces" command = "FLOW_PROFILE=release ./scripts/deploy.sh" description = "Deploy CLI with traces updates (f traces)" [[tasks]] name = "rl-signals-tail" command = "sh -lc \"mkdir -p out/logs && touch ${FLOW_RL_SIGNALS_PATH:-out/logs/flow_rl_signals.jsonl} && tail -f ${FLOW_RL_SIGNALS_PATH:-out/logs/flow_rl_signals.jsonl}\"" description = "Follow structured RL signal events emitted by flow runtime." [[tasks]] name = "rl-signals-summary" command = "python3 ./scripts/rl_signal_summary.py ${FLOW_RL_SIGNALS_PATH:-out/logs/flow_rl_signals.jsonl} $@" description = "Summarize RL signal counts, errors, and latency percentiles." [[tasks]] name = "rl-dataset-build" command = "python3 ./scripts/build_rl_runtime_dataset.py --harbor-dir ${HARBOR_DIR:-$HOME/repos/laude-institute/harbor} --flow-signals ${FLOW_RL_SIGNALS_PATH:-out/logs/flow_rl_signals.jsonl} --seq-mem ${SEQ_CH_MEM_PATH:-$HOME/.config/flow/rl/seq_mem.jsonl} --write-latest $@" description = "Build Harbor-ready RL dataset snapshot from flow+seq runtime signals." [[tasks]] name = "rl-dataset-validate" command = "python3 -c \"import json, os, pathlib, sys; root = pathlib.Path(os.path.expanduser(os.environ.get('HARBOR_DIR', '~/repos/laude-institute/harbor'))); p = root / 'data' / 'flow_runtime_prepared' / 'latest' / 'validation_report.json'; (print(f'missing validation report: {p}') or sys.exit(1)) if not p.exists() else None; obj = json.loads(p.read_text(encoding='utf-8')); print(json.dumps(obj, indent=2)); sys.exit(0 if obj.get('ok') else 1)\"" description = "Print and enforce latest RL dataset validation report." [[tasks]] name = "rl-capture-on-all" command = "sh -lc \"cd $HOME/code/seq && f rl-capture-on && f agent-qa-capture-on && f agent-qa-capture-status\"" description = "Enable seq low-latency capture + always-on Claude/Codex Q/A ingest." [[tasks]] name = "rl-capture-status" command = "sh -lc \"cd $HOME/code/seq && f agent-qa-capture-status && f rl-signal-summary --last ${LAST:-5000}\"" description = "Show seq Q/A ingest status and current high-signal event density." [[tasks]] name = "rl-capture-logs" command = "sh -lc \"cd $HOME/code/seq && f agent-qa-capture-logs\"" description = "Tail background Claude/Codex Q/A ingest daemon logs from seq." [[tasks]] name = "deploy-with-hub-reload" command = "FLOW_PROFILE=release ./scripts/deploy.sh && f hub stop && FLOW_DOCS_FOCUS=1 f hub" description = "Deploy and restart hub daemons (lin + docs)" [[tasks]] name = "release-build" command = "bash ./scripts/package-release.sh" description = "Build signed release artifacts into dist/ (set FLOW_VERSION, CODESIGN_IDENTITY env vars as needed)" [[tasks]] name = "release" command = "./scripts/release.sh" description = "Build darwin/arm64 release, publish to host, update install snippet" [[tasks]] name = "release-host-setup" command = "infra release setup --path ." description = "Install Caddy and configure a release host (uses infra host set + [release])" [[tasks]] name = "release-publish" command = "bash -c 'tarball=$(ls -t dist/flow_*_darwin_arm64.tar.gz | head -n1) && infra release publish \"$tarball\" --path .'" description = "Upload the latest darwin/arm64 release tarball to the release host" [[tasks]] name = "verify-install-latest-release" command = "bash ./scripts/verify-install-latest-release.sh" description = "Verify that curl -fsSL https://myflow.sh/install.sh | sh installs the current latest stable release in a fresh temp HOME" [[tasks]] name = "ci-blacksmith-status" command = "python3 ./scripts/ci_blacksmith.py status" description = "Show CI runner mode for canary/release workflows (github, blacksmith, or host)" [[tasks]] name = "ci-blacksmith-enable" command = "python3 ./scripts/ci_blacksmith.py enable" description = "Switch Linux CI lanes to Blacksmith runners and enable Linux host SIMD job" [[tasks]] name = "ci-blacksmith-enable-apply" command = "python3 ./scripts/ci_blacksmith.py enable --commit --push" description = "Enable Blacksmith CI mode, commit workflow updates, and push" [[tasks]] name = "ci-blacksmith-disable" command = "python3 ./scripts/ci_blacksmith.py disable" description = "Switch CI back to GitHub-hosted Linux runners and disable Linux host SIMD job" [[tasks]] name = "ci-blacksmith-disable-apply" command = "python3 ./scripts/ci_blacksmith.py disable --commit --push" description = "Disable Blacksmith CI mode, commit workflow updates, and push" [[tasks]] name = "ci-host-enable" command = "python3 ./scripts/ci_blacksmith.py host" description = "Keep standard Linux CI lanes on GitHub runners, but enable Linux host SIMD build on ci-1focus self-hosted runner" [[tasks]] name = "ci-host-enable-apply" command = "python3 ./scripts/ci_blacksmith.py host --commit --push" description = "Enable host SIMD CI mode, commit workflow updates, and push" [[tasks]] name = "ci-host-runner-status" command = "python3 ./scripts/ci_host_runner.py status --repo nikivdev/flow" description = "Show ci-1focus runner service status on infra host and GitHub runner registration state" [[tasks]] name = "ci-host-runner-install" command = "python3 ./scripts/ci_host_runner.py install --repo nikivdev/flow" description = "Install/register self-hosted GitHub runner on configured infra Linux host (label: ci-1focus)" [[tasks]] name = "ci-host-runner-remove" command = "python3 ./scripts/ci_host_runner.py remove --repo nikivdev/flow" description = "Unregister ci-1focus self-hosted GitHub runner and remove runner service on infra host" [[tasks]] name = "ci-host-bootstrap" command = "python3 ./scripts/ci_host_runner.py install --repo nikivdev/flow && python3 ./scripts/ci_blacksmith.py host" description = "One-command setup: install ci-1focus runner on infra host and switch workflows to host SIMD mode" [[tasks]] name = "ci-host-bootstrap-apply" command = "python3 ./scripts/ci_host_runner.py install --repo nikivdev/flow && python3 ./scripts/ci_blacksmith.py host --commit --push" description = "Install ci-1focus runner, switch workflows to host SIMD mode, then commit and push workflow updates" [[tasks]] name = "ci-host-setup" command = "bash ./scripts/ci_host_setup.sh $@" description = "One command to set up ci.1focus.ai runner and switch workflows to host mode (optionally pass user@ip)" [[tasks]] name = "vendor-typesense-up" command = "bash -lc 'cd \"$HOME/code/infra/base\" && ./scripts/typesense.sh up'" description = "Start local Typesense via infra/base shared launcher" [[tasks]] name = "vendor-typesense-setup" command = "bash -lc 'cd \"$HOME/code/infra/base\" && ./scripts/typesense-flox-setup.sh'" description = "One-time setup for local Typesense via flox in infra/base" [[tasks]] name = "vendor-typesense-down" command = "bash -lc 'cd \"$HOME/code/infra/base\" && ./scripts/typesense.sh down'" description = "Stop local Typesense via infra/base shared launcher" [[tasks]] name = "vendor-typesense-status" command = "bash -lc 'cd \"$HOME/code/infra/base\" && ./scripts/typesense.sh status'" description = "Show local Typesense status via infra/base shared launcher" [[tasks]] name = "vendor-typesense-logs" command = "bash -lc 'cd \"$HOME/code/infra/base\" && ./scripts/typesense.sh logs'" description = "Tail local Typesense logs via infra/base shared launcher" [[tasks]] name = "vendor-code-sources" command = "python3 ./scripts/vendor/typesense_code_index.py --project . sources $@" description = "Print opensrc-style source inventory for first-party + vendored crates" [[tasks]] name = "vendor-code-index" command = "python3 ./scripts/vendor/typesense_code_index.py --project . index $@" description = "Index first-party and vendored code chunks into Typesense" [[tasks]] name = "vendor-code-search" command = "python3 ./scripts/vendor/typesense_code_index.py --project . search $@" description = "Search indexed code (chunks by default) with optional scope/crate/lang filters" [[tasks]] name = "vendor-code-search-sources" command = "python3 ./scripts/vendor/typesense_code_index.py --project . search --collection sources $@" description = "Search source inventory metadata (crate/version/path/upstream)" [[tasks]] name = "vendor-trims" command = "bash ./scripts/vendor/apply-trims.sh $@" description = "Apply deterministic trim + warning-hygiene patches to vendored crates" [[tasks]] name = "vendor-rough-audit" command = "python3 ./scripts/vendor/rough_edges_audit.py --project . $@" description = "Audit vendoring rough edges (lock/manifest/patch/provenance/index freshness)" [[tasks]] name = "vendor-rough-audit-strict" command = "python3 ./scripts/vendor/rough_edges_audit.py --project . --strict-warnings $@" description = "Strict vendoring audit: warnings fail (good for pre-merge hardening)" [[tasks]] name = "vendor-offenders" command = "bash ./scripts/vendor/offenders.sh" description = "Rank dependency offenders by tree size and show duplicate-version pressure" [[tasks]] name = "vendor-bench-iter" command = "python3 ./scripts/vendor/bench_iteration.py --project . $@" description = "Record compile-iteration benchmark samples to out/vendor/iteration_bench.jsonl" [[tasks]] name = "vendor-optimize-loop" command = "bash ./scripts/vendor/optimize_loop.sh $@" description = "Run audit + offender scan + iteration benchmark in one command" [[tasks]] name = "update-deps" command = "bash ./scripts/vendor/update-deps.sh $@" description = "One-command dependency refresh: sync vendored crates to latest, trim/harden, pin vendor lock, and validate" [[tasks]] name = "test-flow-task-tracing" command = "bun i-run.ts" [[tasks]] name = "test-flow-server-tracing" command = "bun i-server.ts" [[tasks]] name = "test-log-server" command = "bun tests/test_log_server.ts" description = "Test log ingestion and query endpoints (requires 'f server' running)" [[tasks]] name = "bench-ai-runtime" command = "python3 ./scripts/bench-ai-runtime.py $@" description = "Benchmark MoonBit AI task runtime paths (moon-run vs cached vs daemon)" [[tasks]] name = "bench-cli-startup" command = "python3 ./scripts/bench-cli-startup.py $@" description = "Benchmark Flow CLI startup and cheap read-only command latency" [[tasks]] name = "bench-cli-gate" command = "sh -lc 'CARGO_INCREMENTAL=0 cargo build --release --bin f && python3 ./scripts/bench-cli-startup.py --flow-bin ./target/release/f --json-out ./out/bench/cli-startup.json \"$@\" && python3 ./scripts/check_cli_startup_thresholds.py ./out/bench/cli-startup.json' -- $@" description = "Benchmark release CLI startup and fail if repository latency thresholds regress." [[tasks]] name = "bench-ffi-boundary" command = "python3 ./scripts/bench-moonbit-rust-ffi.py $@" description = "Benchmark MoonBit <-> Rust FFI boundary ns/op overhead" [[tasks]] name = "myflow-commit-session-smoke" command = "bash ./scripts/myflow-commit-session-smoke.sh $@" description = "Verify commit sync to myflow and attached Claude/Codex sessions for a repo/commit." shortcuts = ["mcss"] [[tasks]] name = "install-ai-fast-client" command = "mkdir -p \"$HOME/.local/bin\" && cargo build --release -p ai-taskd-client --bin ai-taskd-client && install -m 755 ./target/release/ai-taskd-client \"$HOME/.local/bin/fai\"" description = "Install low-latency ai-taskd client as ~/.local/bin/fai" [[tasks]] name = "ai-taskd-launchd-install" command = "python3 ./scripts/ai-taskd-launchd.py install" description = "Install always-on ai-taskd launch agent (launchd)" [[tasks]] name = "ai-taskd-launchd-uninstall" command = "python3 ./scripts/ai-taskd-launchd.py uninstall" description = "Remove ai-taskd launch agent (launchd)" [[tasks]] name = "ai-taskd-launchd-status" command = "python3 ./scripts/ai-taskd-launchd.py status" description = "Show ai-taskd launch agent status" [[tasks]] name = "ai-taskd-launchd-logs" command = "python3 ./scripts/ai-taskd-launchd.py logs $@" description = "Show ai-taskd launch agent logs" [[tasks]] name = "codex-skill-eval-launchd-install" command = "python3 ./scripts/codex-skill-eval-launchd.py install $@" description = "Install scheduled Codex skill-eval scorecard refresh (launchd)" [[tasks]] name = "codex-skill-eval-launchd-uninstall" command = "python3 ./scripts/codex-skill-eval-launchd.py uninstall" description = "Remove scheduled Codex skill-eval scorecard refresh (launchd)" [[tasks]] name = "codex-skill-eval-launchd-status" command = "python3 ./scripts/codex-skill-eval-launchd.py status" description = "Show scheduled Codex skill-eval launch agent status" [[tasks]] name = "codex-skill-eval-launchd-logs" command = "python3 ./scripts/codex-skill-eval-launchd.py logs $@" description = "Show scheduled Codex skill-eval launch agent logs" [[tasks]] name = "codex-telemetry-status" command = "f codex telemetry status $@" description = "Show optional redacted Codex telemetry export state" [[tasks]] name = "codex-telemetry-flush" command = "f codex telemetry flush $@" description = "Flush unseen redacted Codex telemetry to configured Maple endpoints once" [[tasks]] name = "test-args" command = "echo \"arg1=$1 arg2=$2 all=$@\"" description = "Test that task args are passed correctly" [[tasks]] name = "clone-test-repo" command = "git clone https://github.com/nikivdev/flow-testing testing/flow-testing" description = "Clone the flow-testing repo into testing/" [[tasks]] name = "agents" command = "bash ./scripts/agents-switch.sh $@" description = "Switch agents.md profile in a repo" interactive = true [[tasks]] name = "run-root" command = "bash ./scripts/run-repos.sh root" description = "Print run repo root path (default: ~/run)" [[tasks]] name = "run-ensure" command = "bash ./scripts/run-repos.sh ensure" description = "Ensure local run repo root exists" [[tasks]] name = "run-list" command = "bash ./scripts/run-repos.sh list" description = "List run repos under ~/run (flow.toml + git metadata)" [[tasks]] name = "run-load" command = "bash ./scripts/run-repos.sh load $@" description = "Clone or update a run repo. Usage: f run-load <name> <repo-ssh-url> [branch]" [[tasks]] name = "run-sync" command = "bash ./scripts/run-repos.sh sync $@" description = "Sync run repo(s). Usage: f run-sync [name]" [[tasks]] name = "run-task" command = "bash ./scripts/run-repos.sh task $@" description = "Run a Flow task inside a run repo. Usage: f run-task <name> <task> [args...]" [[tasks]] name = "run-exec" command = "bash ./scripts/run-repos.sh exec $@" description = "Ensure run repo exists (clone/sync) and run a task. Usage: f run-exec <name> <repo-ssh-url> [--branch <branch>] <task> [args...]" [[tasks]] name = "run-ri" command = "bash ./scripts/run-repos.sh ri $@" description = "Run a task in the internal run repo (~/run/i). Usage: f ri <task> [args...]" shortcuts = ["ri"] [[tasks]] name = "run-r" command = "bash ./scripts/run-repos.sh r $@" description = "Run a task in the public run repo (~/run). Usage: f r <task> [args...]" shortcuts = ["r"] [[tasks]] name = "run-rp" command = "bash ./scripts/run-repos.sh rp $@" description = "Run a task in a run project. Usage: f rp <project> <task> [args...] (resolves ~/run/<project> or ~/run/i/<project>)" shortcuts = ["rp"] [[tasks]] name = "run-rip" command = "bash ./scripts/run-repos.sh rip $@" description = "Run a task in an internal run project. Usage: f rip <project> <task> [args...] (maps to ~/run/i/<project>)" shortcuts = ["rip"] [storage] provider = "myflow.sh" env_var = "FLOW_SECRETS_TOKEN" # optional; defaults to this value [[storage.envs]] name = "local" description = "Local development defaults" variables = [ { key = "DATABASE_URL", default = "" }, { key = "OPENAI_API_KEY", default = "" }, { key = "ANTHROPIC_API_KEY", default = "" }, { key = "FLOW_RL_SIGNALS", default = "true" }, { key = "FLOW_RL_SIGNALS_PATH", default = "out/logs/flow_rl_signals.jsonl" }, { key = "FLOW_RL_SIGNALS_SEQ_MIRROR", default = "true" }, { key = "FLOW_RL_SIGNALS_SEQ_PATH", default = "~/.config/flow/rl/seq_mem.jsonl" }, { key = "FLOW_ROUTER_SUGGESTED_TASK", default = "" }, { key = "FLOW_ROUTER_OVERRIDE_REASON", default = "" }, { key = "FLOW_RL_SIGNAL_TEXT", default = "snippet" }, { key = "FLOW_RL_SIGNAL_MAX_CHARS", default = "4000" }, { key = "FLOW_CODEX_MAPLE_LOCAL_ENDPOINT", default = "" }, { key = "FLOW_CODEX_MAPLE_LOCAL_INGEST_KEY", default = "" }, { key = "FLOW_CODEX_MAPLE_HOSTED_ENDPOINT", default = "" }, { key = "FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY", default = "" }, { key = "FLOW_CODEX_MAPLE_HOSTED_PUBLIC_INGEST_KEY", default = "" }, { key = "FLOW_CODEX_MAPLE_TRACES_ENDPOINTS", default = "" }, { key = "FLOW_CODEX_MAPLE_INGEST_KEYS", default = "" }, { key = "FLOW_CODEX_MAPLE_SERVICE_NAME", default = "flow-codex" }, { key = "FLOW_CODEX_MAPLE_SERVICE_VERSION", default = "" }, { key = "FLOW_CODEX_MAPLE_SCOPE_NAME", default = "flow.codex" }, { key = "FLOW_CODEX_MAPLE_ENV", default = "local" }, { key = "FLOW_CODEX_MAPLE_QUEUE_CAPACITY", default = "1024" }, { key = "FLOW_CODEX_MAPLE_MAX_BATCH_SIZE", default = "64" }, { key = "FLOW_CODEX_MAPLE_FLUSH_INTERVAL_MS", default = "100" }, { key = "FLOW_CODEX_MAPLE_CONNECT_TIMEOUT_MS", default = "400" }, { key = "FLOW_CODEX_MAPLE_REQUEST_TIMEOUT_MS", default = "800" }, { key = "MAPLE_API_TOKEN", default = "" }, { key = "MAPLE_MCP_URL", default = "https://api.maple.dev/mcp" }, { key = "SEQ_CH_MEM_PATH", default = "~/.config/flow/rl/seq_mem.jsonl" }, { key = "HARBOR_DIR", default = "~/repos/laude-institute/harbor" }, ] # ============================================================================ # Swarm Demo Tasks - Run with: uv run flow <command> "<task>" # ============================================================================ [[tasks]] name = "swarm-single" command = "uv run flow.py single \"$@\"" description = "Single agent demo" [[tasks]] name = "swarm-seq" command = "uv run flow.py sequential \"$@\"" description = "Sequential workflow (Researcher -> Analyst -> Writer)" [[tasks]] name = "swarm-parallel" command = "uv run flow.py concurrent \"$@\"" description = "Concurrent agents (Optimist, Critic, Pragmatist)" [[tasks]] name = "swarm-hier" command = "uv run flow.py hierarchical \"$@\"" description = "Hierarchical swarm (Director with workers)" [[tasks]] name = "swarm-rearrange" command = "uv run flow.py rearrange \"$@\"" description = "Agent rearrange with custom flow" [[tasks]] name = "swarm-chat" command = "uv run flow.py chat \"$@\"" description = "Group chat discussion" [[tasks]] name = "swarm-auto" command = "uv run flow.py auto \"$@\"" description = "Auto-generate a swarm for a task" [[tasks]] name = "pond-build" command = "bash -c 'cd ~/repos/ghostty-org/ghostty && zig build && zig build -Doptimize=ReleaseFast'" description = "Build Pond (Ghostty fork) in debug and production modes" [[tasks]] name = "pond-debug" command = "bash -c 'cd ~/repos/ghostty-org/ghostty && zig build -Doptimize=Debug'" description = "Build Pond (Ghostty fork) in debug mode" [[tasks]] name = "zed-build-debug-release" command = "bash -c 'cd ~/repos/zed-industries/zed && ./script/bundle-mac -d && ./script/bundle-mac'" description = "Build Zed macOS bundle in debug mode (fast) and then in release mode" [[tasks]] name = "linsa-assistant-serve" command = "bash -c 'cd ~/code/org/linsa/linsa/api/cpp && sh ./run.sh serve'" description = "Build + run the Linsa C++ assistant proxy server" [[tasks]] name = "linsa-assistant-stream" command = "bash -c 'curl -N -X POST http://127.0.0.1:8788/api/assistant/chat/stream -H \"Content-Type: application/json\" -d \"{\\\"message\\\":\\\"hello\\\"}\"'" description = "Stream assistant response via SSE from the local proxy" [[tasks]] name = "x-deploy" command = "bash -lc 'bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh install && bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh run build'" description = "Install and build the zvec X likes/bookmarks CLI workspace with the local Bun debug build" [[tasks]] name = "x-doctor" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts doctor $@" description = "Inspect zvec X archive settings, discovered query ids, and auth prerequisites" [[tasks]] name = "x-import-file" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts import-file $@" description = "Ingest a bookmarks.json or likes.json export into the zvec X archive" [[tasks]] name = "x-capture-js" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts capture-js" description = "Print a browser console script that captures bookmarks or likes into JSON" [[tasks]] name = "x-sync" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts sync --source all $@" description = "Incrementally sync X bookmarks and likes into ~/repos/alibaba/zvec/var/x" [[tasks]] name = "x-backfill" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts backfill --source all $@" description = "Fetch as much of the bookmarks and likes timelines as X will return into the zvec archive" [[tasks]] name = "x-sync-loop" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts watch --source all $@" description = "Poll X continuously and keep the zvec archive and search index current" [[tasks]] name = "x-search" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts search $@" description = "Search the local zvec X archive across bookmarks and likes" [[tasks]] name = "x-list" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts list $@" description = "List recent items from the local zvec X archive" [[tasks]] name = "x-stats" command = "bash ~/repos/alibaba/zvec/tools/x/run-local-bun.sh ~/repos/alibaba/zvec/tools/x/src/cli.ts stats $@" description = "Show counts and latest-seen timestamps for the local zvec X archive" [[tasks]] name = "siftly-x" command = "bash ~/repos/viperrcrypto/Siftly/scripts/run-local-bun.sh run siftly -- x $@" description = "Run the Siftly CLI wrapper for the zvec X workspace with the local Bun debug build" [[tasks]] name = "siftly-x-deploy" command = "bash ~/repos/viperrcrypto/Siftly/scripts/run-local-bun.sh run siftly -- x deploy $@" description = "Install and build the zvec X workspace for use through the Siftly CLI with the local Bun debug build" [[tasks]] name = "codex-fork-status" command = "python3 ./scripts/codex_fork.py status \"$@\"" description = "Show the Codex fork home checkout, worktree, and last-session state" [[tasks]] name = "codex-fork-sync" command = "python3 ./scripts/codex_fork.py sync \"$@\"" description = "Fast-forward nikiv in ~/repos/nikivdev/codex to upstream/main and optionally push private" [[tasks]] name = "codex-fork-task" command = "python3 ./scripts/codex_fork.py task \"$@\"" description = "Create or reuse a scoped Codex fork worktree and open the matching Codex session" interactive = true [[tasks]] name = "codex-fork-last" command = "python3 ./scripts/codex_fork.py last \"$@\"" description = "Resume the last Codex fork session from the last used worktree" interactive = true [[tasks]] name = "codex-fork-promote" command = "python3 ./scripts/codex_fork.py promote \"$@\"" description = "Create or update a review/nikiv-* branch from a codex/* worktree branch" [skills] sync_tasks = true install = ["quality-bun-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false [commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] quality-bun-feature-delivery = 2 [commit.testing] mode = "block" runner = "bun" bun_repo_strict = true require_related_tests = true ai_scratch_test_dir = ".ai/test" run_ai_scratch_tests = true allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 20 ================================================ FILE: install.sh ================================================ #!/bin/sh set -eu # Flow CLI installer # Usage: curl -fsSL https://myflow.sh/install.sh | sh # Security posture: # - We require SHA-256 verification by default. # - Set FLOW_INSTALL_INSECURE=1 (or true/yes) to bypass verification. #region logging if [ "${FLOW_DEBUG-}" = "true" ] || [ "${FLOW_DEBUG-}" = "1" ]; then debug() { echo "$@" >&2; } else debug() { :; } fi if [ "${FLOW_QUIET-}" = "1" ] || [ "${FLOW_QUIET-}" = "true" ]; then info() { :; } else info() { echo "$@" >&2; } fi error() { echo "error: $@" >&2 exit 1 } is_truthy() { case "${1:-}" in 1|true|TRUE|yes|YES|y|Y) return 0 ;; *) return 1 ;; esac } FLOW_SHIM_DIR_SELECTED="" can_execute_flow_binary() { bin_path="$1" if [ ! -f "$bin_path" ]; then return 1 fi if [ ! -x "$bin_path" ]; then chmod +x "$bin_path" 2>/dev/null || true fi "$bin_path" --version >/dev/null 2>&1 } #endregion #region platform detection get_os() { os="$(uname -s)" if [ "$os" = Darwin ]; then echo "macos" elif [ "$os" = Linux ]; then echo "linux" else error "unsupported OS: $os" fi } get_arch() { arch="$(uname -m)" if [ "$arch" = x86_64 ]; then echo "x64" elif [ "$arch" = aarch64 ] || [ "$arch" = arm64 ]; then echo "arm64" else error "unsupported architecture: $arch" fi } get_target() { os="$1" arch="$2" case "$os-$arch" in macos-x64) echo "x86_64-apple-darwin" ;; macos-arm64) echo "aarch64-apple-darwin" ;; linux-x64) echo "x86_64-unknown-linux-gnu" ;; linux-arm64) echo "aarch64-unknown-linux-gnu" ;; *) error "unsupported platform: $os-$arch" ;; esac } shasum_bin() { if command -v shasum >/dev/null 2>&1; then echo "shasum -a 256" elif command -v sha256sum >/dev/null 2>&1; then echo "sha256sum" else echo "" fi } validate_repo() { repo="$1" if [ -z "${repo:-}" ]; then error "FLOW_UPGRADE_REPO is empty" fi owner="${repo%/*}" name="${repo#*/}" if [ "$owner" = "$repo" ] || [ "$name" = "$repo" ]; then error "invalid repo '${repo}' (expected owner/repo)" fi case "$owner" in */*) error "invalid repo '${repo}' (expected owner/repo)" ;; esac case "$name" in */*) error "invalid repo '${repo}' (expected owner/repo)" ;; esac case "$owner" in *[!A-Za-z0-9._-]*) error "invalid repo owner '${owner}' (allowed: A-Z a-z 0-9 . _ -)" ;; esac case "$name" in *[!A-Za-z0-9._-]*) error "invalid repo name '${name}' (allowed: A-Z a-z 0-9 . _ -)" ;; esac } validate_token() { token="$1" if [ -z "${token:-}" ]; then error "GitHub token is empty" fi case "$token" in *[!A-Za-z0-9._-]*) error "invalid GitHub token characters (refusing to use it)" ;; esac } validate_version() { version="$1" case "$version" in v*) tag="${version#v}" ;; *) tag="$version" ;; esac case "$tag" in ""|*[!0-9A-Za-z._-]*) error "invalid release version '${version}'" ;; esac } #endregion should_install_source() { case "${FLOW_INSTALL_SOURCE:-1}" in 0|false|FALSE|no|NO|n|N) return 1 ;; *) return 0 ;; esac } should_install_path_shim() { case "${FLOW_INSTALL_PATH_SHIM:-1}" in 0|false|FALSE|no|NO|n|N) return 1 ;; *) return 0 ;; esac } ensure_flow_source_checkout() { if ! should_install_source; then info "flow: skipping source checkout (FLOW_INSTALL_SOURCE=0)" return 0 fi if ! command -v git >/dev/null 2>&1; then error "git is required to install flow source to ~/code/flow (or set FLOW_INSTALL_SOURCE=0)" fi source_dir="${FLOW_SOURCE_DIR:-$HOME/code/flow}" source_repo="${FLOW_SOURCE_REPO_URL:-https://github.com/nikivdev/flow.git}" source_branch="${FLOW_SOURCE_BRANCH:-main}" mkdir -p "$(dirname "$source_dir")" if [ -d "$source_dir/.git" ]; then info "flow: source checkout found at $source_dir" if ! git -C "$source_dir" diff --quiet >/dev/null 2>&1 || ! git -C "$source_dir" diff --cached --quiet >/dev/null 2>&1; then info "flow: warning: source checkout has local changes; skipping auto-sync" return 0 fi if git -C "$source_dir" fetch --all --prune >/dev/null 2>&1; then if git -C "$source_dir" show-ref --verify --quiet "refs/remotes/origin/$source_branch"; then if ! git -C "$source_dir" checkout "$source_branch" >/dev/null 2>&1; then info "flow: warning: failed to checkout '$source_branch'; leaving current branch" fi if ! git -C "$source_dir" pull --ff-only origin "$source_branch" >/dev/null 2>&1; then info "flow: warning: failed to fast-forward source checkout; sync manually" fi fi else info "flow: warning: failed to fetch source checkout" fi return 0 fi if [ -e "$source_dir" ]; then error "flow source path exists but is not a git checkout: $source_dir" fi info "flow: cloning source checkout to $source_dir" if ! git clone --branch "$source_branch" "$source_repo" "$source_dir" >/dev/null 2>&1; then error "failed to clone flow source from $source_repo" fi } find_shim_dir() { if [ -n "${FLOW_SHIM_DIR:-}" ]; then if [ ! -d "$FLOW_SHIM_DIR" ]; then mkdir -p "$FLOW_SHIM_DIR" 2>/dev/null || true fi if [ -d "$FLOW_SHIM_DIR" ] && [ -w "$FLOW_SHIM_DIR" ]; then echo "$FLOW_SHIM_DIR" return 0 fi fi old_ifs="${IFS:- }" IFS=':' for dir in ${PATH:-}; do [ -n "$dir" ] || continue [ "$dir" = "." ] && continue [ -d "$dir" ] || continue [ -w "$dir" ] || continue echo "$dir" IFS="$old_ifs" return 0 done IFS="$old_ifs" fallback="$HOME/.local/bin" if [ ! -d "$fallback" ]; then mkdir -p "$fallback" 2>/dev/null || true fi if [ -d "$fallback" ] && [ -w "$fallback" ]; then echo "$fallback" return 0 fi return 1 } install_path_shim() { if ! should_install_path_shim; then return 0 fi install_path="${FLOW_INSTALL_PATH:-$HOME/.flow/bin/f}" install_dir="$(dirname "$install_path")" shim_dir="$(find_shim_dir 2>/dev/null || true)" if [ -z "${shim_dir:-}" ]; then info "flow: warning: no writable PATH directory found for immediate command shim" return 0 fi FLOW_SHIM_DIR_SELECTED="$shim_dir" for name in f flow; do target="$shim_dir/$name" # Never overwrite the installed binary with a symlink to itself. if [ "$target" = "$install_path" ]; then continue fi if [ -e "$target" ] && [ ! -L "$target" ]; then # Do not replace existing non-symlink binaries/scripts. continue fi ln -sf "$install_path" "$target" 2>/dev/null || true done if [ "$shim_dir" != "$install_dir" ]; then info "flow: command shim installed in $shim_dir" fi } ensure_line_in_file() { file="$1" needle="$2" line="$3" parent="$(dirname "$file")" [ -d "$parent" ] || mkdir -p "$parent" 2>/dev/null || true [ -f "$file" ] || touch "$file" 2>/dev/null || true if ! grep -F -q "$needle" "$file" 2>/dev/null; then printf '%s\n' "$line" >> "$file" fi } ensure_sh_path_entry() { file="$1" dir="$2" ensure_line_in_file "$file" "$dir" "export PATH=\"$dir:\$PATH\"" } #region download helpers download_file() { url="$1" file="$2" if command -v curl >/dev/null 2>&1; then debug ">" curl -fsSL -o "$file" "$url" if [ "${FLOW_DEBUG-}" = "true" ] || [ "${FLOW_DEBUG-}" = "1" ]; then curl -fsSL --proto '=https' --tlsv1.2 -o "$file" "$url" else curl -fsSL --proto '=https' --tlsv1.2 -o "$file" "$url" 2>/dev/null fi elif command -v wget >/dev/null 2>&1; then debug ">" wget -qO "$file" "$url" wget -qO "$file" "$url" else error "curl or wget is required" fi } fetch_url() { url="$1" if command -v curl >/dev/null 2>&1; then case "$url" in https://api.github.com/*) token="${GITHUB_TOKEN:-${GH_TOKEN:-${FLOW_GITHUB_TOKEN:-}}}" if [ -n "${token:-}" ]; then validate_token "$token" curl -fsSL --proto '=https' --tlsv1.2 -H "Authorization: Bearer ${token}" "$url" else curl -fsSL --proto '=https' --tlsv1.2 "$url" fi ;; *) curl -fsSL --proto '=https' --tlsv1.2 "$url" ;; esac elif command -v wget >/dev/null 2>&1; then wget -qO- "$url" else error "curl or wget is required" fi } get_latest_version() { repo="${FLOW_UPGRADE_REPO:-}" if [ -z "${repo:-}" ] && [ -n "${FLOW_GITHUB_OWNER:-}" ] && [ -n "${FLOW_GITHUB_REPO:-}" ]; then repo="${FLOW_GITHUB_OWNER}/${FLOW_GITHUB_REPO}" fi repo="${repo:-nikivdev/flow}" validate_repo "$repo" url="https://api.github.com/repos/${repo}/releases/latest" version="$(fetch_url "$url" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')" validate_version "$version" echo "$version" } get_checksum() { version="$1" target="$2" repo="${FLOW_UPGRADE_REPO:-}" if [ -z "${repo:-}" ] && [ -n "${FLOW_GITHUB_OWNER:-}" ] && [ -n "${FLOW_GITHUB_REPO:-}" ]; then repo="${FLOW_GITHUB_OWNER}/${FLOW_GITHUB_REPO}" fi repo="${repo:-nikivdev/flow}" validate_repo "$repo" url="https://github.com/${repo}/releases/download/${version}/checksums.txt" checksums="$(fetch_url "$url" 2>/dev/null)" || return 1 echo "$checksums" | grep "flow-${target}.tar.gz" | awk '{print $1}' } get_checksum_for_file() { version="$1" file="$2" repo="${FLOW_UPGRADE_REPO:-}" if [ -z "${repo:-}" ] && [ -n "${FLOW_GITHUB_OWNER:-}" ] && [ -n "${FLOW_GITHUB_REPO:-}" ]; then repo="${FLOW_GITHUB_OWNER}/${FLOW_GITHUB_REPO}" fi repo="${repo:-nikivdev/flow}" validate_repo "$repo" url="https://github.com/${repo}/releases/download/${version}/checksums.txt" checksums="$(fetch_url "$url" 2>/dev/null)" || return 1 # checksums.txt format: "<sha256> <filename>" echo "$checksums" | awk -v f="$file" '$2==f {print $1}' } #endregion install_flow() { version="${FLOW_VERSION:-latest}" os="${FLOW_OS:-$(get_os)}" arch="${FLOW_ARCH:-$(get_arch)}" target="$(get_target "$os" "$arch")" install_path="${FLOW_INSTALL_PATH:-$HOME/.flow/bin/f}" install_dir="$(dirname "$install_path")" info "flow: installing flow CLI..." info "flow: platform: $os-$arch ($target)" # Get latest version if needed if [ "$version" = "latest" ]; then info "flow: fetching latest version..." version="$(get_latest_version)" if [ -z "$version" ]; then error "failed to fetch latest version" fi fi validate_version "$version" info "flow: version: $version" # URLs - try CDN first, fallback to GitHub cdn_url="https://cdn.myflow.sh/${version}/flow-${target}.tar.gz" repo="${FLOW_UPGRADE_REPO:-}" if [ -z "${repo:-}" ] && [ -n "${FLOW_GITHUB_OWNER:-}" ] && [ -n "${FLOW_GITHUB_REPO:-}" ]; then repo="${FLOW_GITHUB_OWNER}/${FLOW_GITHUB_REPO}" fi repo="${repo:-nikivdev/flow}" validate_repo "$repo" github_url="https://github.com/${repo}/releases/download/${version}/flow-${target}.tar.gz" download_dir="$(mktemp -d)" tarball="$download_dir/flow.tar.gz" download_source="unknown" asset_file="flow-${target}.tar.gz" legacy_os="$os" if [ "$legacy_os" = "macos" ]; then legacy_os="darwin" fi legacy_arch="amd64" if [ "$arch" = "arm64" ]; then legacy_arch="arm64" fi legacy_file="flow_${version}_${legacy_os}_${legacy_arch}.tar.gz" legacy_url="https://github.com/${repo}/releases/download/${version}/${legacy_file}" # Try CDN first (faster) info "flow: downloading..." if command -v curl >/dev/null 2>&1 && curl -fsSL -o "$tarball" "$cdn_url" 2>/dev/null; then debug "flow: downloaded from CDN" download_source="cdn" else debug "flow: trying GitHub..." if download_file "$github_url" "$tarball"; then asset_file="flow-${target}.tar.gz" download_source="github" elif download_file "$legacy_url" "$tarball"; then asset_file="$legacy_file" download_source="legacy" else error "download failed" fi fi # Verify checksum if available shasum="$(shasum_bin)" if [ -n "$shasum" ]; then expected="$(get_checksum_for_file "$version" "$asset_file" 2>/dev/null)" || true if [ -z "${expected:-}" ]; then # Back-compat: allow checksums.txt to contain either naming scheme. if [ "$asset_file" = "$legacy_file" ]; then expected="$(get_checksum_for_file "$version" "flow-${target}.tar.gz" 2>/dev/null)" || true elif [ "$asset_file" = "flow-${target}.tar.gz" ]; then expected="$(get_checksum_for_file "$version" "$legacy_file" 2>/dev/null)" || true fi fi if [ -z "${expected:-}" ]; then if is_truthy "${FLOW_INSTALL_INSECURE-}"; then info "flow: warning: checksum not verified (FLOW_INSTALL_INSECURE=1)" elif [ "${download_source:-}" = "cdn" ]; then rm -rf "$download_dir" "$extract_dir" 2>/dev/null || true error "checksum verification failed for CDN download (checksums.txt missing or entry not found). Refusing to install.\nSet FLOW_INSTALL_INSECURE=1 to bypass (not recommended)." else info "flow: warning: checksum not verified (checksums.txt missing or entry not found; legacy release?)" expected="" fi fi if [ -n "${expected:-}" ]; then debug "flow: verifying checksum..." actual="$($shasum "$tarball" | awk '{print $1}')" if [ "$expected" != "$actual" ]; then rm -rf "$download_dir" error "checksum mismatch" fi info "flow: checksum verified" fi else if is_truthy "${FLOW_INSTALL_INSECURE-}"; then info "flow: warning: sha256 tool not found, skipping checksum verification (FLOW_INSTALL_INSECURE=1)" else error "sha256 tool not found (need shasum or sha256sum). Refusing to install.\nSet FLOW_INSTALL_INSECURE=1 to bypass (not recommended)." fi fi # Extract and install mkdir -p "$install_dir" extract_dir="$(mktemp -d)" tar -xzf "$tarball" -C "$extract_dir" # Find binary if [ -f "$extract_dir/f" ]; then mv "$extract_dir/f" "$install_path" else binary="$(find "$extract_dir" -type f \( -name "f" -o -name "flow" \) 2>/dev/null | head -1)" if [ -z "$binary" ]; then binary="$(find "$extract_dir" -type f -perm +111 2>/dev/null | head -1)" fi if [ -z "$binary" ]; then rm -rf "$download_dir" "$extract_dir" error "binary not found in archive" fi mv "$binary" "$install_path" fi chmod +x "$install_path" # Provide both `f` and `flow` as entrypoints. base="$(basename "$install_path")" if [ "$base" = "f" ]; then if [ -e "$install_dir/flow" ] && [ -d "$install_dir/flow" ]; then info "flow: warning: cannot create symlink $install_dir/flow (path is a directory)" else ln -sf "f" "$install_dir/flow" 2>/dev/null || true fi elif [ "$base" = "flow" ]; then if [ -e "$install_dir/f" ] && [ -d "$install_dir/f" ]; then info "flow: warning: cannot create symlink $install_dir/f (path is a directory)" else ln -sf "flow" "$install_dir/f" 2>/dev/null || true fi fi # Cleanup rm -rf "$download_dir" "$extract_dir" if ! can_execute_flow_binary "$install_path"; then if [ "$os" = "macos" ] && ! is_truthy "${FLOW_INSTALL_RETRY_ALT_ARCH:-0}"; then alt_arch="x64" if [ "$arch" = "x64" ]; then alt_arch="arm64" fi info "flow: installed binary failed execution; retrying with macos-$alt_arch build" FLOW_ARCH="$alt_arch" FLOW_INSTALL_RETRY_ALT_ARCH=1 install_flow return 0 fi info "flow: diagnostic: unable to execute $install_path" info "flow: diagnostic: $(ls -l "$install_path" 2>/dev/null || echo missing)" if command -v file >/dev/null 2>&1; then info "flow: diagnostic: $(file "$install_path" 2>/dev/null || true)" fi error "installed flow binary is not executable on this host" fi info "flow: installed to $install_path" } configure_shell() { install_dir="$(dirname "${FLOW_INSTALL_PATH:-$HOME/.flow/bin/f}")" shim_dir="${FLOW_SHIM_DIR_SELECTED:-}" fallback_shim="$HOME/.local/bin" registry_url="${FLOW_REGISTRY_URL:-https://myflow.sh}" # Fish if [ -f "$HOME/.config/fish/config.fish" ]; then ensure_line_in_file "$HOME/.config/fish/config.fish" "$install_dir" "fish_add_path $install_dir" if [ -n "$shim_dir" ] && [ "$shim_dir" != "$install_dir" ]; then ensure_line_in_file "$HOME/.config/fish/config.fish" "$shim_dir" "fish_add_path $shim_dir" fi ensure_line_in_file "$HOME/.config/fish/config.fish" "$fallback_shim" "fish_add_path $fallback_shim" ensure_line_in_file "$HOME/.config/fish/config.fish" "FLOW_REGISTRY_URL" "set -gx FLOW_REGISTRY_URL \"$registry_url\"" info "flow: updated ~/.config/fish/config.fish" fi # Zsh for zsh_rc in "$HOME/.zshrc" "$HOME/.zprofile"; do ensure_sh_path_entry "$zsh_rc" "$install_dir" if [ -n "$shim_dir" ] && [ "$shim_dir" != "$install_dir" ]; then ensure_sh_path_entry "$zsh_rc" "$shim_dir" fi ensure_sh_path_entry "$zsh_rc" "$fallback_shim" ensure_line_in_file "$zsh_rc" "FLOW_REGISTRY_URL" "export FLOW_REGISTRY_URL=\"$registry_url\"" done info "flow: updated ~/.zshrc and ~/.zprofile" # Bash bash_updated=0 for rc in "$HOME/.bashrc" "$HOME/.bash_profile"; do if [ -f "$rc" ]; then ensure_sh_path_entry "$rc" "$install_dir" if [ -n "$shim_dir" ] && [ "$shim_dir" != "$install_dir" ]; then ensure_sh_path_entry "$rc" "$shim_dir" fi ensure_sh_path_entry "$rc" "$fallback_shim" ensure_line_in_file "$rc" "FLOW_REGISTRY_URL" "export FLOW_REGISTRY_URL=\"$registry_url\"" bash_updated=1 fi done if [ "$bash_updated" = "0" ]; then rc="$HOME/.bashrc" ensure_sh_path_entry "$rc" "$install_dir" if [ -n "$shim_dir" ] && [ "$shim_dir" != "$install_dir" ]; then ensure_sh_path_entry "$rc" "$shim_dir" fi ensure_sh_path_entry "$rc" "$fallback_shim" ensure_line_in_file "$rc" "FLOW_REGISTRY_URL" "export FLOW_REGISTRY_URL=\"$registry_url\"" fi } after_install() { source_dir="${FLOW_SOURCE_DIR:-$HOME/code/flow}" install_path="${FLOW_INSTALL_PATH:-$HOME/.flow/bin/f}" if [ ! -x "$install_path" ]; then chmod +x "$install_path" 2>/dev/null || true fi if ! can_execute_flow_binary "$install_path"; then if command -v xattr >/dev/null 2>&1; then xattr -d com.apple.quarantine "$install_path" >/dev/null 2>&1 || true fi chmod +x "$install_path" 2>/dev/null || true fi if ! can_execute_flow_binary "$install_path"; then info "flow: diagnostic: unable to execute fallback binary: $install_path" info "flow: diagnostic: $(ls -l "$install_path" 2>/dev/null || echo missing)" if command -v file >/dev/null 2>&1; then info "flow: diagnostic: $(file "$install_path" 2>/dev/null || true)" fi error "installed flow binary is not runnable; rerun with FLOW_DEBUG=1 and share diagnostics" fi info "" info "flow: installed successfully!" if command -v f >/dev/null 2>&1; then info "flow: command ready: $(command -v f)" elif [ -n "${FLOW_SHIM_DIR_SELECTED:-}" ] && [ -x "${FLOW_SHIM_DIR_SELECTED}/f" ]; then info "flow: command shim ready: ${FLOW_SHIM_DIR_SELECTED}/f" info "flow: open a new shell to refresh PATH (zsh: exec zsh -l)" else info "flow: OPEN NEW SHELL to use 'f' by name" info "flow: immediate fallback: $install_path --help" fi if should_install_source; then info "flow: source checkout: $source_dir" fi info "flow: then run 'f --help' to get started" info "flow: docs: https://myflow.sh" } should_setup_run() { case "${FLOW_SETUP_RUN:-1}" in 0|false|FALSE|no|NO|n|N) return 1 ;; *) return 0 ;; esac } is_interactive() { [ -t 0 ] && [ -t 1 ] } prompt_value() { label="$1" default="$2" env_override="$3" if [ -n "$env_override" ]; then echo "$env_override" return 0 fi if ! is_interactive; then echo "$default" return 0 fi printf '%s [%s]: ' "$label" "$default" >&2 read -r answer </dev/tty || answer="" answer="${answer:-$default}" echo "$answer" } prompt_yn() { question="$1" default="${2:-y}" if ! is_interactive; then case "$default" in y|Y) return 0 ;; *) return 1 ;; esac fi if [ "$default" = "y" ]; then hint="Y/n" else hint="y/N" fi printf '%s (%s): ' "$question" "$hint" >&2 read -r answer </dev/tty || answer="" answer="${answer:-$default}" case "$answer" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } clone_if_missing() { target_dir="$1" repo_url="$2" label="$3" if [ -d "$target_dir/.git" ]; then info "flow: $label already exists at $target_dir" return 0 fi if [ -e "$target_dir" ] && [ ! -d "$target_dir" ]; then info "flow: warning: $target_dir exists but is not a directory; skipping $label" return 1 fi if [ -z "$repo_url" ] || [ "$repo_url" = "-" ] || [ "$repo_url" = "skip" ]; then info "flow: skipping $label (no repo URL)" return 0 fi mkdir -p "$(dirname "$target_dir")" info "flow: cloning $label -> $target_dir" if git clone "$repo_url" "$target_dir" >/dev/null 2>&1; then info "flow: $label ready" else info "flow: warning: failed to clone $label from $repo_url" return 1 fi } setup_run_repos() { if ! should_setup_run; then info "flow: skipping ~/run setup (FLOW_SETUP_RUN=0)" return 0 fi if ! command -v git >/dev/null 2>&1; then info "flow: warning: git not found; skipping ~/run setup" return 0 fi run_root="${RUN_ROOT:-$HOME/run}" # If both already exist, skip entirely if [ -d "$run_root/.git" ] && [ -d "$run_root/i/.git" ]; then info "flow: ~/run and ~/run/i already set up" return 0 fi info "" if ! prompt_yn "Set up ~/run repos (task collections)?"; then info "flow: skipping ~/run setup" info "flow: you can set this up later with: f run-load" return 0 fi # ~/run (public) if [ ! -d "$run_root/.git" ]; then run_url="$(prompt_value "~/run repo URL (SSH or HTTPS)" "git@github.com:nikivdev/run.git" "${FLOW_RUN_REPO:-}")" clone_if_missing "$run_root" "$run_url" "~/run" fi # ~/run/i (internal/private) if [ ! -d "$run_root/i/.git" ]; then run_i_url="$(prompt_value "~/run/i repo URL (SSH or HTTPS, or 'skip')" "git@github.com:nikivdev/run-i.git" "${FLOW_RUN_I_REPO:-}")" clone_if_missing "$run_root/i" "$run_i_url" "~/run/i" fi info "flow: ~/run repos ready" info "flow: run tasks with: f r <task> (public) or f ri <task> (internal)" } install_flow install_path_shim ensure_flow_source_checkout configure_shell setup_run_repos after_install ================================================ FILE: lib/vendor-manifest/axum.toml ================================================ crate = "axum" version = "0.8.8" source = "crates.io" synced_at_utc = "2026-02-22T18:24:59Z" history_repo = "lib/vendor-history/axum.git" history_head = "fffdbe0b3d192ef263fda82d186a45c18aac3466" materialized_path = "lib/vendor/axum" sync_cmd = "scripts/vendor/inhouse-crate.sh axum 0.8.8" ================================================ FILE: lib/vendor-manifest/clap.toml ================================================ crate = "clap" version = "4.6.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" crate_archive_sha256 = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" checksum_match = "yes" upstream_repository = "https://github.com/clap-rs/clap" upstream_homepage = "" synced_at_utc = "2026-03-18T18:43:50Z" history_repo = "lib/vendor-history/clap.git" history_head = "2b9744adfa21ccac060f42fce80cfeebfbcebbe5" materialized_path = "lib/vendor/clap" sync_cmd = "scripts/vendor/inhouse-crate.sh clap 4.6.0" ================================================ FILE: lib/vendor-manifest/crossterm.toml ================================================ crate = "crossterm" version = "0.29.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" crate_archive_sha256 = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" checksum_match = "yes" upstream_repository = "https://github.com/crossterm-rs/crossterm" upstream_homepage = "" synced_at_utc = "2026-03-09T16:48:35Z" history_repo = "lib/vendor-history/crossterm.git" history_head = "da02bb3cfe73f6f2da5cdd42ec7575c69f9eb75f" materialized_path = "lib/vendor/crossterm" sync_cmd = "scripts/vendor/inhouse-crate.sh crossterm 0.29.0" ================================================ FILE: lib/vendor-manifest/crypto_secretbox.toml ================================================ crate = "crypto_secretbox" version = "0.1.1" source = "crates.io" synced_at_utc = "2026-02-22T18:28:40Z" history_repo = "lib/vendor-history/crypto_secretbox.git" history_head = "0a1c004e1f9c97e92b405455a6a31305cda49902" materialized_path = "lib/vendor/crypto_secretbox" sync_cmd = "scripts/vendor/inhouse-crate.sh crypto_secretbox 0.1.1" ================================================ FILE: lib/vendor-manifest/ctrlc.toml ================================================ crate = "ctrlc" version = "3.5.2" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" crate_archive_sha256 = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" checksum_match = "yes" upstream_repository = "https://github.com/Detegr/rust-ctrlc.git" upstream_homepage = "https://github.com/Detegr/rust-ctrlc" synced_at_utc = "2026-03-09T16:48:36Z" history_repo = "lib/vendor-history/ctrlc.git" history_head = "d1e8661047f0425f7a10d44fe3cf04391a9e59b0" materialized_path = "lib/vendor/ctrlc" sync_cmd = "scripts/vendor/inhouse-crate.sh ctrlc 3.5.2" ================================================ FILE: lib/vendor-manifest/futures.toml ================================================ crate = "futures" version = "0.3.32" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" crate_archive_sha256 = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" checksum_match = "no" upstream_repository = "https://github.com/rust-lang/futures-rs" upstream_homepage = "https://rust-lang.github.io/futures-rs" synced_at_utc = "2026-03-09T16:48:37Z" history_repo = "lib/vendor-history/futures.git" history_head = "250364bfd7ce80eee9074e4baf9d3f134f84e11a" materialized_path = "lib/vendor/futures" sync_cmd = "scripts/vendor/inhouse-crate.sh futures 0.3.32" ================================================ FILE: lib/vendor-manifest/hmac.toml ================================================ crate = "hmac" version = "0.12.1" source = "crates.io" synced_at_utc = "2026-02-22T19:00:48Z" history_repo = "lib/vendor-history/hmac.git" history_head = "392173235d4c2340c040a971e9945d2fd057576b" materialized_path = "lib/vendor/hmac" sync_cmd = "scripts/vendor/inhouse-crate.sh hmac 0.12.1" ================================================ FILE: lib/vendor-manifest/ignore.toml ================================================ crate = "ignore" version = "0.4.25" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" crate_archive_sha256 = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" checksum_match = "yes" upstream_repository = "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore" upstream_homepage = "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore" synced_at_utc = "2026-02-23T09:23:22Z" history_repo = "lib/vendor-history/ignore.git" history_head = "932118a48f5d111ef261e1e108bbf356269d618f" materialized_path = "lib/vendor/ignore" sync_cmd = "scripts/vendor/inhouse-crate.sh ignore 0.4.25" ================================================ FILE: lib/vendor-manifest/notify-debouncer-mini.toml ================================================ crate = "notify-debouncer-mini" version = "0.7.0" source = "crates.io" synced_at_utc = "2026-02-22T19:37:32Z" history_repo = "lib/vendor-history/notify-debouncer-mini.git" history_head = "9ef9af7ca1216d1c855d9b34bf8e7faebc73c5ba" materialized_path = "lib/vendor/notify-debouncer-mini" sync_cmd = "scripts/vendor/inhouse-crate.sh notify-debouncer-mini 0.7.0" ================================================ FILE: lib/vendor-manifest/notify.toml ================================================ crate = "notify" version = "8.2.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" crate_archive_sha256 = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" checksum_match = "yes" upstream_repository = "https://github.com/notify-rs/notify.git" upstream_homepage = "https://github.com/notify-rs/notify" synced_at_utc = "2026-02-23T09:31:51Z" history_repo = "lib/vendor-history/notify.git" history_head = "752b8136c01b100bfc8b8e425f12ef319e97d5d1" materialized_path = "lib/vendor/notify" sync_cmd = "scripts/vendor/inhouse-crate.sh notify 8.2.0" ================================================ FILE: lib/vendor-manifest/portable-pty.toml ================================================ crate = "portable-pty" version = "0.9.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" crate_archive_sha256 = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" checksum_match = "yes" upstream_repository = "https://github.com/wezterm/wezterm" upstream_homepage = "" synced_at_utc = "2026-03-09T16:48:39Z" history_repo = "lib/vendor-history/portable-pty.git" history_head = "06a5eafe103dc7040e47ad88ae43f3cf52bbfc92" materialized_path = "lib/vendor/portable-pty" sync_cmd = "scripts/vendor/inhouse-crate.sh portable-pty 0.9.0" ================================================ FILE: lib/vendor-manifest/ratatui.toml ================================================ crate = "ratatui" version = "0.30.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" crate_archive_sha256 = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" checksum_match = "yes" upstream_repository = "https://github.com/ratatui/ratatui" upstream_homepage = "https://ratatui.rs" synced_at_utc = "2026-03-09T16:48:40Z" history_repo = "lib/vendor-history/ratatui.git" history_head = "cb656d8aa7b019f66ac65383bfe2d304c9373ec5" materialized_path = "lib/vendor/ratatui" sync_cmd = "scripts/vendor/inhouse-crate.sh ratatui 0.30.0" ================================================ FILE: lib/vendor-manifest/regex.toml ================================================ crate = "regex" version = "1.12.3" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" crate_archive_sha256 = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" checksum_match = "yes" upstream_repository = "https://github.com/rust-lang/regex" upstream_homepage = "https://github.com/rust-lang/regex" synced_at_utc = "2026-03-09T16:49:10Z" history_repo = "lib/vendor-history/regex.git" history_head = "140d77161444f8975123b2c394d360022d17a949" materialized_path = "lib/vendor/regex" sync_cmd = "scripts/vendor/inhouse-crate.sh regex 1.12.3" ================================================ FILE: lib/vendor-manifest/reqwest.toml ================================================ crate = "reqwest" version = "0.13.2" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" crate_archive_sha256 = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" checksum_match = "yes" upstream_repository = "https://github.com/seanmonstar/reqwest" upstream_homepage = "" synced_at_utc = "2026-03-09T16:49:10Z" history_repo = "lib/vendor-history/reqwest.git" history_head = "b0189fa9fb0cba412eb64d96992558da9cd6372f" materialized_path = "lib/vendor/reqwest" sync_cmd = "scripts/vendor/inhouse-crate.sh reqwest 0.13.2" ================================================ FILE: lib/vendor-manifest/rmp-serde.toml ================================================ crate = "rmp-serde" version = "1.3.1" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" crate_archive_sha256 = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" checksum_match = "yes" upstream_repository = "https://github.com/3Hren/msgpack-rust" upstream_homepage = "" synced_at_utc = "2026-02-23T09:28:28Z" history_repo = "lib/vendor-history/rmp-serde.git" history_head = "0a515b86613c05ab18f14483d8e47fca9a43101d" materialized_path = "lib/vendor/rmp-serde" sync_cmd = "scripts/vendor/inhouse-crate.sh rmp-serde 1.3.1" ================================================ FILE: lib/vendor-manifest/rusqlite.toml ================================================ crate = "rusqlite" version = "0.39.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" crate_archive_sha256 = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" checksum_match = "yes" upstream_repository = "https://github.com/rusqlite/rusqlite" upstream_homepage = "" synced_at_utc = "2026-03-18T18:43:53Z" history_repo = "lib/vendor-history/rusqlite.git" history_head = "ec74f8123a7ab7973c969dd538a681e5838461a4" materialized_path = "lib/vendor/rusqlite" sync_cmd = "scripts/vendor/inhouse-crate.sh rusqlite 0.39.0" ================================================ FILE: lib/vendor-manifest/serde.toml ================================================ crate = "serde" version = "1.0.228" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" crate_archive_sha256 = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum_match = "yes" upstream_repository = "https://github.com/serde-rs/serde" upstream_homepage = "https://serde.rs" synced_at_utc = "2026-02-23T09:32:11Z" history_repo = "lib/vendor-history/serde.git" history_head = "eb6297112aad0f87ddbb8223d95617fd6c8c815b" materialized_path = "lib/vendor/serde" sync_cmd = "scripts/vendor/inhouse-crate.sh serde 1.0.228" ================================================ FILE: lib/vendor-manifest/sha1.toml ================================================ crate = "sha1" version = "0.10.6" source = "crates.io" synced_at_utc = "2026-02-22T18:57:02Z" history_repo = "lib/vendor-history/sha1.git" history_head = "1a7bcebde8f534fb40a8b08e3a38f3c0244e23d9" materialized_path = "lib/vendor/sha1" sync_cmd = "scripts/vendor/inhouse-crate.sh sha1 0.10.6" ================================================ FILE: lib/vendor-manifest/sha2.toml ================================================ crate = "sha2" version = "0.10.9" source = "crates.io" synced_at_utc = "2026-02-22T18:57:49Z" history_repo = "lib/vendor-history/sha2.git" history_head = "1aa3f56560840c076acfbb3cb7183f2c2b789814" materialized_path = "lib/vendor/sha2" sync_cmd = "scripts/vendor/inhouse-crate.sh sha2 0.10.9" ================================================ FILE: lib/vendor-manifest/tokio-stream.toml ================================================ crate = "tokio-stream" version = "0.1.18" source = "crates.io" synced_at_utc = "2026-02-22T18:36:31Z" history_repo = "lib/vendor-history/tokio-stream.git" history_head = "94ba626bae40a85be086bcef2b10f297f6e4d3b4" materialized_path = "lib/vendor/tokio-stream" sync_cmd = "scripts/vendor/inhouse-crate.sh tokio-stream 0.1.18" ================================================ FILE: lib/vendor-manifest/tokio.toml ================================================ crate = "tokio" version = "1.50.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" crate_archive_sha256 = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" checksum_match = "yes" upstream_repository = "https://github.com/tokio-rs/tokio" upstream_homepage = "https://tokio.rs" synced_at_utc = "2026-03-09T16:49:10Z" history_repo = "lib/vendor-history/tokio.git" history_head = "65a5636827a1b6dcc7f808d8cc71be60342db1b6" materialized_path = "lib/vendor/tokio" sync_cmd = "scripts/vendor/inhouse-crate.sh tokio 1.50.0" ================================================ FILE: lib/vendor-manifest/toml.toml ================================================ crate = "toml" version = "1.0.7+spec-1.1.0" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" crate_archive_sha256 = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" checksum_match = "yes" upstream_repository = "https://github.com/toml-rs/toml" upstream_homepage = "" synced_at_utc = "2026-03-18T18:43:56Z" history_repo = "lib/vendor-history/toml.git" history_head = "9bc4b3f4804194d8a42535001fb7c9ddfde6f2b0" materialized_path = "lib/vendor/toml" sync_cmd = "scripts/vendor/inhouse-crate.sh toml 1.0.7+spec-1.1.0" ================================================ FILE: lib/vendor-manifest/tower-http.toml ================================================ crate = "tower-http" version = "0.6.8" source = "crates.io" synced_at_utc = "2026-02-22T18:25:01Z" history_repo = "lib/vendor-history/tower-http.git" history_head = "954fa60bf1e962dc30919a13414833d7efac8e98" materialized_path = "lib/vendor/tower-http" sync_cmd = "scripts/vendor/inhouse-crate.sh tower-http 0.6.8" ================================================ FILE: lib/vendor-manifest/tracing-subscriber.toml ================================================ crate = "tracing-subscriber" version = "0.3.23" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" crate_archive_sha256 = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" checksum_match = "yes" upstream_repository = "https://github.com/tokio-rs/tracing" upstream_homepage = "https://tokio.rs" synced_at_utc = "2026-03-18T18:43:57Z" history_repo = "lib/vendor-history/tracing-subscriber.git" history_head = "86291ff1c974187a82c5d33b310940c01f1ed3a8" materialized_path = "lib/vendor/tracing-subscriber" sync_cmd = "scripts/vendor/inhouse-crate.sh tracing-subscriber 0.3.23" ================================================ FILE: lib/vendor-manifest/url.toml ================================================ crate = "url" version = "2.5.8" source = "crates.io" synced_at_utc = "2026-02-22T18:25:03Z" history_repo = "lib/vendor-history/url.git" history_head = "42e98d5f89b624f6d1bb7d3e57f887c74461aed2" materialized_path = "lib/vendor/url" sync_cmd = "scripts/vendor/inhouse-crate.sh url 2.5.8" ================================================ FILE: lib/vendor-manifest/x25519-dalek.toml ================================================ crate = "x25519-dalek" version = "2.0.1" source = "crates.io" registry_index = "https://github.com/rust-lang/crates.io-index" cargo_registry_checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" crate_archive_sha256 = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" checksum_match = "yes" upstream_repository = "https://github.com/dalek-cryptography/curve25519-dalek/tree/main/x25519-dalek" upstream_homepage = "https://github.com/dalek-cryptography/curve25519-dalek" synced_at_utc = "2026-02-23T09:23:30Z" history_repo = "lib/vendor-history/x25519-dalek.git" history_head = "b3c01403757dcebd4c603f0f9c28dc924be49d75" materialized_path = "lib/vendor/x25519-dalek" sync_cmd = "scripts/vendor/inhouse-crate.sh x25519-dalek 2.0.1" ================================================ FILE: license ================================================ MIT License Copyright (c) nikiv.dev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: pyproject.toml ================================================ [project] name = "flow" version = "0.1.0" description = "CLI to demonstrate swarms in action" requires-python = ">=3.10" dependencies = [ "swarms>=8.9.0", "rich>=13.0.0", ] [project.scripts] flow = "flow:main" [tool.uv] package = true [build-system] requires = ["hatchling"] build-backend = "hatchling.build" ================================================ FILE: readme.md ================================================ # [flow](https://myflow.sh) > Everything you need to move your project faster ## Install Install the latest release (macOS/Linux): ```sh curl -fsSL https://myflow.sh/install.sh | sh ``` Then run: ```sh ~/.flow/bin/f --version ~/.flow/bin/f doctor ``` If `f` is not found by name immediately, open a new shell (`exec zsh -l` on zsh). The installer verifies SHA-256 checksums when available. If you are installing a legacy release that doesn't ship `checksums.txt`, it will warn and continue (GitHub download only). To bypass verification explicitly (not recommended), set `FLOW_INSTALL_INSECURE=1`. ## Upgrade Upgrade to the latest release: ```sh f upgrade ``` Upgrade to the latest canary build: ```sh f upgrade --canary ``` Switch back to stable: ```sh f upgrade --stable ``` If you fork Flow (or publish releases under a different repo), set: - `FLOW_UPGRADE_REPO=owner/repo` - `FLOW_GITHUB_TOKEN` (or `GITHUB_TOKEN` / `GH_TOKEN`) to avoid GitHub API rate limits If you are upgrading to a very old tag that doesn't ship `checksums.txt`, you can force bypassing checksum verification with `FLOW_UPGRADE_INSECURE=1` (not recommended). ## Build From Source Clone Flow, hydrate the pinned vendor snapshot, and install an optimized local build: ```sh git clone https://github.com/nikivdev/flow.git cd flow ./scripts/vendor/vendor-repo.sh hydrate FLOW_PROFILE=release ./scripts/deploy.sh ~/bin/f --version ``` - `./scripts/vendor/vendor-repo.sh hydrate` reuses `.vendor/flow-vendor` if it already exists. - If `.vendor/flow-vendor` is missing, it clones the pinned vendor repo from [`vendor.lock.toml`](vendor.lock.toml) and materializes `lib/vendor/*` from that exact commit. - The pinned vendor repo is public: `https://github.com/nikivdev/flow-vendor` - `FLOW_PROFILE=release ./scripts/deploy.sh` builds the optimized release binary and installs `f` / `flow` / `lin` into `~/bin` (and symlinks into `~/.local/bin` if that directory exists). If you want to populate the vendor checkout yourself first, that works too: ```sh git clone https://github.com/nikivdev/flow-vendor.git .vendor/flow-vendor ./scripts/vendor/vendor-repo.sh hydrate ``` ## Dev Fast Typical local loop: ```sh f setup f test f deploy ``` - `f setup` checks the workspace and toolchain. - `f test` runs the test suite. - `f deploy` builds and installs the local CLI into your path. If you want to inspect tasks first: ```sh f tasks list ``` ## Features To see the current CLI surface: ```sh f --help ``` For deeper docs, read [`docs/`](docs). ## Supported Platforms Release artifacts are built for: - macOS: `arm64`, `x86_64` - Linux (glibc): `arm64`, `x86_64` ## Contributing Use Flow and AI. For the full command surface, run `f --help`. For project docs and workflows, read [`docs/`](docs). [![Discord](https://go.nikiv.dev/badge-discord)](https://go.nikiv.dev/discord) [![X](https://go.nikiv.dev/badge-x)](https://x.com/nikivdev) [![nikiv.dev](https://go.nikiv.dev/badge-nikiv)](https://nikiv.dev) ================================================ FILE: scripts/agents-switch.sh ================================================ #!/usr/bin/env bash set -euo pipefail cmd="${1:-}" profile="${2:-}" repo="${3:-$(pwd)}" if [[ -z "$cmd" ]]; then echo "Usage:" echo " f agents <profile> [repo]" echo " f agents rules [profile] [repo]" exit 1 fi if [[ "$cmd" == "rules" ]]; then if [[ -n "$profile" && -d "$profile" && -z "${3:-}" ]]; then repo="$profile" profile="" elif [[ -n "${3:-}" ]]; then repo="${3}" fi if [[ ! -d "$repo" ]]; then echo "Repo not found: $repo" exit 1 fi if [[ ! -d "$repo/agents" ]]; then echo "No agents/ directory in $repo" exit 1 fi mapfile -t profiles < <(ls "$repo"/agents/agents.*.md "$repo"/agents/AGENTS.*.md 2>/dev/null | sed -E 's#.*/(AGENTS|agents)\\.##; s#\\.md$##' | sort -u) if [[ ${#profiles[@]} -eq 0 ]]; then echo "No profiles found in $repo/agents" exit 1 fi if [[ -n "$profile" ]]; then if [[ ! -f "$repo/agents/agents.${profile}.md" && ! -f "$repo/agents/AGENTS.${profile}.md" ]]; then echo "Missing profile: $repo/agents/agents.${profile}.md" exit 1 fi else if command -v fzf >/dev/null 2>&1; then profile="$(printf '%s\n' "${profiles[@]}" | fzf --prompt="agents> " --height=40% --border)" else echo "fzf not found; using numbered selection." select choice in "${profiles[@]}"; do profile="$choice" break done fi if [[ -z "$profile" ]]; then echo "No profile selected." exit 1 fi fi elif [[ -z "$profile" ]]; then if [[ -d "$repo/agents" && -f "$repo/agents/.default" ]]; then profile="$(cat "$repo/agents/.default" | tr -d '[:space:]')" else echo "Usage:" echo " f agents <profile> [repo]" echo " f agents rules [profile] [repo]" exit 1 fi fi if [[ ! -d "$repo" ]]; then echo "Repo not found: $repo" exit 1 fi candidate="$repo/agents/agents.${profile}.md" if [[ ! -f "$candidate" ]]; then candidate="$repo/agents/AGENTS.${profile}.md" if [[ ! -f "$candidate" ]]; then echo "Missing profile: $candidate" exit 1 fi fi cp "$candidate" "$repo/agents.md" echo "$profile" > "$repo/agents/.default" echo "Activated agents.md -> $candidate" echo "Default profile set to: $profile" ================================================ FILE: scripts/ai-taskd-launchd.py ================================================ #!/usr/bin/env python3 import argparse import os import plistlib import shutil import subprocess import sys from pathlib import Path LABEL = "dev.nikiv.flow-ai-taskd" def run(cmd: list[str]) -> subprocess.CompletedProcess: return subprocess.run(cmd, text=True, capture_output=True, check=False) def resolve_f_bin(repo_root: Path) -> str: env_override = os.environ.get("FLOW_AI_TASKD_F_BIN", "").strip() if env_override: return env_override which_f = shutil.which("f") if which_f: return which_f for candidate in [ repo_root / "target" / "release" / "f", repo_root / "target" / "debug" / "f", ]: if candidate.exists(): return str(candidate) raise SystemExit("Could not resolve f binary. Build flow first or set FLOW_AI_TASKD_F_BIN.") def plist_path() -> Path: return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist" def domain_target() -> str: return f"gui/{os.getuid()}/{LABEL}" def install(repo_root: Path) -> int: f_bin = resolve_f_bin(repo_root) p = plist_path() p.parent.mkdir(parents=True, exist_ok=True) log_dir = Path.home() / ".flow" / "logs" log_dir.mkdir(parents=True, exist_ok=True) payload = { "Label": LABEL, "ProgramArguments": [f_bin, "tasks", "daemon", "serve"], "RunAtLoad": True, "KeepAlive": True, "StandardOutPath": str(log_dir / "ai-taskd.launchd.stdout.log"), "StandardErrorPath": str(log_dir / "ai-taskd.launchd.stderr.log"), "EnvironmentVariables": { "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", "FLOW_AI_TASKD_TIMINGS_LOG": os.environ.get("FLOW_AI_TASKD_TIMINGS_LOG", "0"), }, } with p.open("wb") as f: plistlib.dump(payload, f) run(["launchctl", "bootout", f"gui/{os.getuid()}", str(p)]) b = run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(p)]) if b.returncode != 0: print(b.stderr.strip(), file=sys.stderr) return b.returncode run(["launchctl", "kickstart", "-k", domain_target()]) print(f"loaded: {domain_target()}") print(f"plist: {p}") print(f"f_bin: {f_bin}") return 0 def uninstall() -> int: p = plist_path() run(["launchctl", "bootout", f"gui/{os.getuid()}", str(p)]) if p.exists(): p.unlink() print(f"unloaded: {domain_target()}") print(f"removed: {p}") return 0 def status() -> int: out = run(["launchctl", "print", domain_target()]) if out.returncode != 0: print(f"{domain_target()}: not loaded") if out.stderr.strip(): print(out.stderr.strip()) return 0 print(out.stdout, end="") return 0 def logs(lines: int) -> int: log_dir = Path.home() / ".flow" / "logs" stdout = log_dir / "ai-taskd.launchd.stdout.log" stderr = log_dir / "ai-taskd.launchd.stderr.log" for path in [stdout, stderr]: print(f"==> {path}") if not path.exists(): print("(missing)") continue text = path.read_text(encoding="utf-8", errors="replace").splitlines() for line in text[-lines:]: print(line) return 0 def main() -> int: parser = argparse.ArgumentParser(description="Manage launchd service for ai-taskd.") sub = parser.add_subparsers(dest="cmd", required=True) sub.add_parser("install") sub.add_parser("uninstall") sub.add_parser("status") p_logs = sub.add_parser("logs") p_logs.add_argument("--lines", type=int, default=120) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[1] if args.cmd == "install": return install(repo_root) if args.cmd == "uninstall": return uninstall() if args.cmd == "status": return status() if args.cmd == "logs": return logs(args.lines) return 1 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/bench-ai-runtime.py ================================================ #!/usr/bin/env python3 import argparse import json import math import os import re import statistics import subprocess import sys import time from pathlib import Path from typing import Dict, List, Tuple def run_cmd(cmd: List[str], cwd: Path, env: Dict[str, str] | None = None, capture: bool = True) -> subprocess.CompletedProcess: merged_env = os.environ.copy() if env: merged_env.update(env) return subprocess.run( cmd, cwd=str(cwd), env=merged_env, text=True, capture_output=capture, check=False, ) def pct(values_us: List[float], p: float) -> float: if not values_us: return math.nan vals = sorted(values_us) idx = int(math.ceil((p / 100.0) * len(vals))) - 1 idx = max(0, min(idx, len(vals) - 1)) return vals[idx] def summarize(values_us: List[float]) -> Dict[str, float]: return { "n": float(len(values_us)), "min_us": min(values_us), "p50_us": pct(values_us, 50), "p95_us": pct(values_us, 95), "p99_us": pct(values_us, 99), "mean_us": statistics.fmean(values_us), "max_us": max(values_us), } def benchmark_command( *, label: str, cmd: List[str], cwd: Path, env: Dict[str, str] | None, warmup: int, iterations: int, ) -> Tuple[str, Dict[str, float]]: for _ in range(warmup): proc = run_cmd(cmd, cwd=cwd, env=env) if proc.returncode != 0: raise RuntimeError(f"warmup failed for {label}: {' '.join(cmd)}\n{proc.stderr}") durations_us: List[float] = [] for _ in range(iterations): start = time.perf_counter_ns() proc = run_cmd(cmd, cwd=cwd, env=env) end = time.perf_counter_ns() if proc.returncode != 0: raise RuntimeError(f"run failed for {label}: {' '.join(cmd)}\n{proc.stderr}") durations_us.append((end - start) / 1000.0) return label, summarize(durations_us) def find_flow_bin(repo: Path, flow_bin: str | None) -> str: if flow_bin: return flow_bin # Prefer release binary first to reduce stale-protocol mismatch when debug was not rebuilt. for candidate in [repo / "target" / "release" / "f", repo / "target" / "debug" / "f"]: if candidate.exists() and os.access(candidate, os.X_OK): return str(candidate) return "f" def find_ai_taskd_client_bin(repo: Path) -> str | None: for candidate in [ repo / "target" / "release" / "ai-taskd-client", repo / "target" / "debug" / "ai-taskd-client", ]: if candidate.exists() and os.access(candidate, os.X_OK): return str(candidate) return None def ensure_cached_binary(repo: Path, flow_bin: str) -> str: proc = run_cmd([flow_bin, "tasks", "build-ai", "ai:flow/noop"], cwd=repo) if proc.returncode != 0: raise RuntimeError(f"failed to build noop task cache\n{proc.stderr}") match = re.search(r"binary:\s*(.+)", proc.stdout) if not match: raise RuntimeError(f"failed to parse cached binary path from output:\n{proc.stdout}") binary_path = match.group(1).strip() if not os.path.exists(binary_path): raise RuntimeError(f"cached binary path does not exist: {binary_path}") return binary_path def main() -> int: parser = argparse.ArgumentParser(description="Benchmark Flow AI task runtime paths.") parser.add_argument("--iterations", type=int, default=50) parser.add_argument("--warmup", type=int, default=5) parser.add_argument("--flow-bin", default=None) parser.add_argument("--json-out", default="") args = parser.parse_args() if args.iterations <= 0: raise SystemExit("--iterations must be > 0") repo = Path(__file__).resolve().parents[1] flow_bin = find_flow_bin(repo, args.flow_bin) ai_taskd_client_bin = find_ai_taskd_client_bin(repo) print(f"repo: {repo}") print(f"flow_bin: {flow_bin}") if ai_taskd_client_bin: print(f"ai_taskd_client_bin: {ai_taskd_client_bin}") print(f"iterations={args.iterations} warmup={args.warmup}") # ensure daemon is up for daemon path benchmark (restart to avoid stale protocol mismatch) _ = run_cmd([flow_bin, "tasks", "daemon", "stop"], cwd=repo) _ = run_cmd([flow_bin, "tasks", "daemon", "start"], cwd=repo) cached_binary = ensure_cached_binary(repo, flow_bin) scenarios = [ ( "rust_help", [flow_bin, "--help"], None, ), ( "moon_run_noop", [flow_bin, "ai:flow/noop"], {"FLOW_AI_TASK_RUNTIME": "moon-run"}, ), ( "cached_noop", [flow_bin, "ai:flow/noop"], {"FLOW_AI_TASK_RUNTIME": "cached"}, ), ( "daemon_cached_noop", [flow_bin, "tasks", "run-ai", "--daemon", "ai:flow/noop"], None, ), ( "cached_binary_direct", [cached_binary], {"FLOW_AI_TASK_PROJECT_ROOT": str(repo)}, ), ] if ai_taskd_client_bin: scenarios.append( ( "daemon_client_noop", [ai_taskd_client_bin, "ai:flow/noop"], None, ) ) results: Dict[str, Dict[str, float]] = {} for label, cmd, env in scenarios: label, stats = benchmark_command( label=label, cmd=cmd, cwd=repo, env=env, warmup=args.warmup, iterations=args.iterations, ) results[label] = stats print( f"{label:<22} n={int(stats['n'])} p50={stats['p50_us']:.1f}us " f"p95={stats['p95_us']:.1f}us p99={stats['p99_us']:.1f}us mean={stats['mean_us']:.1f}us" ) cached_vs_moon = results["moon_run_noop"]["p95_us"] / results["cached_noop"]["p95_us"] daemon_vs_cached = results["daemon_cached_noop"]["p95_us"] / results["cached_noop"]["p95_us"] print(f"p95 ratio moon_run/cached: {cached_vs_moon:.2f}x") print(f"p95 ratio daemon/cached: {daemon_vs_cached:.2f}x") daemon_client_vs_f = None if "daemon_client_noop" in results: daemon_client_vs_f = ( results["daemon_cached_noop"]["p95_us"] / results["daemon_client_noop"]["p95_us"] ) print(f"p95 ratio f-daemon/client-daemon: {daemon_client_vs_f:.2f}x") payload = { "repo": str(repo), "flow_bin": flow_bin, "iterations": args.iterations, "warmup": args.warmup, "results": results, "ratios": { "moon_run_p95_div_cached_p95": cached_vs_moon, "daemon_p95_div_cached_p95": daemon_vs_cached, "f_daemon_p95_div_client_daemon_p95": daemon_client_vs_f, }, } if args.json_out: out = Path(args.json_out) out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") print(f"wrote: {out}") _ = run_cmd([flow_bin, "tasks", "daemon", "stop"], cwd=repo) return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/bench-cli-startup.py ================================================ #!/usr/bin/env python3 import argparse import json import math import os import statistics import subprocess import time from pathlib import Path from typing import Dict, List, Tuple def pct(values_ms: List[float], p: float) -> float: if not values_ms: return math.nan vals = sorted(values_ms) idx = int(math.ceil((p / 100.0) * len(vals))) - 1 idx = max(0, min(idx, len(vals) - 1)) return vals[idx] def summarize(values_ms: List[float]) -> Dict[str, float]: return { "n": float(len(values_ms)), "min_ms": min(values_ms), "p50_ms": pct(values_ms, 50), "p95_ms": pct(values_ms, 95), "p99_ms": pct(values_ms, 99), "mean_ms": statistics.fmean(values_ms), "max_ms": max(values_ms), } def run_cmd( cmd: List[str], *, cwd: Path, env: Dict[str, str], ) -> subprocess.CompletedProcess[str]: merged_env = os.environ.copy() merged_env.update(env) return subprocess.run( cmd, cwd=str(cwd), env=merged_env, text=True, capture_output=True, check=False, ) def benchmark_command( *, label: str, cmd: List[str], cwd: Path, env: Dict[str, str], warmup: int, iterations: int, ) -> Tuple[str, Dict[str, float]]: for _ in range(warmup): proc = run_cmd(cmd, cwd=cwd, env=env) if proc.returncode != 0: raise RuntimeError( f"warmup failed for {label}: {' '.join(cmd)}\n" f"stdout:\n{proc.stdout}\n" f"stderr:\n{proc.stderr}" ) durations_ms: List[float] = [] for _ in range(iterations): start = time.perf_counter_ns() proc = run_cmd(cmd, cwd=cwd, env=env) end = time.perf_counter_ns() if proc.returncode != 0: raise RuntimeError( f"run failed for {label}: {' '.join(cmd)}\n" f"stdout:\n{proc.stdout}\n" f"stderr:\n{proc.stderr}" ) durations_ms.append((end - start) / 1_000_000.0) return label, summarize(durations_ms) def find_flow_bin(repo: Path, flow_bin: str | None) -> str: if flow_bin: return flow_bin for candidate in [repo / "target" / "release" / "f", repo / "target" / "debug" / "f"]: if candidate.exists() and os.access(candidate, os.X_OK): return str(candidate) return "f" def main() -> int: parser = argparse.ArgumentParser( description="Benchmark Flow CLI startup and low-latency read-only commands." ) parser.add_argument("--iterations", type=int, default=20) parser.add_argument("--warmup", type=int, default=3) parser.add_argument("--flow-bin", default=None) parser.add_argument("--project-root", default=".") parser.add_argument("--json-out", default="") args = parser.parse_args() if args.iterations <= 0: raise SystemExit("--iterations must be > 0") if args.warmup < 0: raise SystemExit("--warmup must be >= 0") repo = Path(__file__).resolve().parents[1] project_root = Path(args.project_root).expanduser() if not project_root.is_absolute(): project_root = (repo / project_root).resolve() flow_bin = find_flow_bin(repo, args.flow_bin) base_env = { "CI": "1", "FLOW_ANALYTICS_DISABLE": "1", } scenarios = [ ("help", [flow_bin, "--help"], repo), ("help_full", [flow_bin, "--help-full"], repo), ("info", [flow_bin, "info"], project_root), ("projects", [flow_bin, "projects"], project_root), ("analytics_status", [flow_bin, "analytics", "status"], project_root), ("tasks_list", [flow_bin, "tasks", "list"], project_root), ("tasks_dupes", [flow_bin, "tasks", "dupes"], project_root), ("deploy_show_host", [flow_bin, "deploy", "show-host"], project_root), ] print(f"repo: {repo}", flush=True) print(f"project_root: {project_root}", flush=True) print(f"flow_bin: {flow_bin}", flush=True) print(f"iterations={args.iterations} warmup={args.warmup}", flush=True) results: Dict[str, Dict[str, float]] = {} for label, cmd, cwd in scenarios: label, stats = benchmark_command( label=label, cmd=cmd, cwd=cwd, env=base_env, warmup=args.warmup, iterations=args.iterations, ) results[label] = stats print( f"{label:<18} n={int(stats['n'])} p50={stats['p50_ms']:.2f}ms " f"p95={stats['p95_ms']:.2f}ms p99={stats['p99_ms']:.2f}ms " f"mean={stats['mean_ms']:.2f}ms", flush=True, ) payload = { "repo": str(repo), "project_root": str(project_root), "flow_bin": flow_bin, "iterations": args.iterations, "warmup": args.warmup, "results": results, } if args.json_out: out = Path(args.json_out).expanduser() if not out.is_absolute(): out = (repo / out).resolve() out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") print(f"wrote: {out}", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/bench-moonbit-rust-ffi.py ================================================ #!/usr/bin/env python3 import argparse import json import os import re import subprocess from pathlib import Path from typing import Dict def run(cmd, cwd: Path, env: Dict[str, str] | None = None) -> subprocess.CompletedProcess: merged = os.environ.copy() if env: merged.update(env) return subprocess.run(cmd, cwd=str(cwd), text=True, capture_output=True, env=merged, check=False) def parse_metrics(text: str) -> Dict[str, Dict[str, float]]: metrics: Dict[str, Dict[str, float]] = {} pattern = re.compile(r"^(\S+)\s+ns_total=(\d+)\s+ns_per_op=([0-9.]+)\s+checksum=(\d+)$") for line in text.splitlines(): m = pattern.match(line.strip()) if not m: continue label = m.group(1) total = int(m.group(2)) per_op = float(m.group(3)) checksum = int(m.group(4)) metrics[label] = { "ns_total": float(total), "ns_per_op_reported": per_op, "checksum": float(checksum), } return metrics def write_moon_pkg(moon_dir: Path, rust_lib_dir: Path, cc_flags: str) -> None: template = (moon_dir / "moon.pkg.template.json").read_text(encoding="utf-8") flags = f"-L{rust_lib_dir} -lflow_ffi_host_boundary" body = template.replace("__CC_FLAGS__", cc_flags).replace("__CC_LINK_FLAGS__", flags) (moon_dir / "moon.pkg.json").write_text(body, encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser(description="Benchmark MoonBit <-> Rust FFI boundary overhead.") parser.add_argument("--iters", type=int, default=10_000_000) parser.add_argument( "--native-opt", action="store_true", help="Enable machine-local tuning (Rust target-cpu=native and Moon cc-flags -O3 -march=native).", ) parser.add_argument("--json-out", default="") args = parser.parse_args() if args.iters <= 0: raise SystemExit("--iters must be > 0") root = Path(__file__).resolve().parents[1] rust_manifest = root / "bench" / "ffi_host_boundary" / "Cargo.toml" rust_dir = rust_manifest.parent moon_dir = root / "bench" / "moon_ffi_boundary" rust_lib_dir = rust_dir / "target" / "release" env = {"FLOW_FFI_ITERS": str(args.iters)} moon_cc_flags = "-O3" if args.native_opt: env["RUSTFLAGS"] = "-C target-cpu=native" moon_cc_flags = "-O3 -march=native -mtune=native" print(f"root: {root}") print(f"iters: {args.iters}") build = run([ "cargo", "build", "--manifest-path", str(rust_manifest), "--release", ], cwd=root) if build.returncode != 0: print(build.stdout) print(build.stderr) raise SystemExit("failed to build rust ffi host crate") write_moon_pkg(moon_dir, rust_lib_dir, moon_cc_flags) rust_proc = run([ "cargo", "run", "--manifest-path", str(rust_manifest), "--release", "--bin", "rust_boundary_bench", "--", "--iters", str(args.iters), ], cwd=root, env=env) if rust_proc.returncode != 0: print(rust_proc.stdout) print(rust_proc.stderr) raise SystemExit("rust benchmark failed") moon_proc = run([ "moon", "-C", str(moon_dir), "run", "main.mbt", "--target", "native", "--release", ], cwd=root, env=env) if moon_proc.returncode != 0: print(moon_proc.stdout) print(moon_proc.stderr) raise SystemExit("moon benchmark failed") rust_metrics = parse_metrics(rust_proc.stdout) moon_metrics = parse_metrics(moon_proc.stdout) required = [ "rust_inline_add", "rust_fn_add", "rust_extern_add", "rust_extern_noop", "moon_ffi_add", "moon_ffi_noop", ] missing = [key for key in required if key not in rust_metrics and key not in moon_metrics] if missing: raise SystemExit(f"missing metrics in output: {missing}") def ns_per_op(metrics: Dict[str, Dict[str, float]], key: str) -> float: return metrics[key]["ns_total"] / float(args.iters) print("--- Rust ---") for key in ["rust_inline_add", "rust_fn_add", "rust_extern_add", "rust_extern_noop"]: if key in rust_metrics: m = rust_metrics[key] print( f"{key:<18} ns/op={ns_per_op(rust_metrics, key):.4f} " f"total_ns={int(m['ns_total'])} checksum={int(m['checksum'])}" ) print("--- MoonBit ---") for key in ["moon_add", "moon_ffi_add", "moon_ffi_noop"]: if key in moon_metrics: m = moon_metrics[key] print( f"{key:<18} ns/op={ns_per_op(moon_metrics, key):.4f} " f"total_ns={int(m['ns_total'])} checksum={int(m['checksum'])}" ) ratios = { "moon_ffi_add_div_rust_extern_add": ns_per_op(moon_metrics, "moon_ffi_add") / ns_per_op(rust_metrics, "rust_extern_add"), "moon_ffi_noop_div_rust_extern_noop": ns_per_op(moon_metrics, "moon_ffi_noop") / ns_per_op(rust_metrics, "rust_extern_noop"), } print("--- Ratios ---") for k, v in ratios.items(): print(f"{k}: {v:.3f}x") payload = { "iters": args.iters, "native_opt": args.native_opt, "rust": rust_metrics, "moon": moon_metrics, "ns_per_op": { "rust_inline_add": ns_per_op(rust_metrics, "rust_inline_add"), "rust_fn_add": ns_per_op(rust_metrics, "rust_fn_add"), "rust_extern_add": ns_per_op(rust_metrics, "rust_extern_add"), "rust_extern_noop": ns_per_op(rust_metrics, "rust_extern_noop"), "moon_ffi_add": ns_per_op(moon_metrics, "moon_ffi_add"), "moon_ffi_noop": ns_per_op(moon_metrics, "moon_ffi_noop"), }, "ratios": ratios, } if "moon_add" in moon_metrics: payload["ns_per_op"]["moon_add"] = ns_per_op(moon_metrics, "moon_add") if args.json_out: out = Path(args.json_out) out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") print(f"wrote: {out}") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/build_rl_runtime_dataset.py ================================================ #!/usr/bin/env python3 """Build Harbor-ready RL dataset snapshots from Flow + Seq runtime traces.""" from __future__ import annotations import argparse import hashlib import json import re from collections import Counter, defaultdict from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any SEQ_HIGH_SIGNAL_PATTERNS = [ r"^seqd\.request$", r"^seqd\.run(\.|$)", r"^cli\.run(\.|$)", r"^cli\.agent$", r"^cli\.open_app_toggle(\.|$)", r"^seq\.sequence\.", r"^menu\.select\.", r"^open_url(\.|$)", r"^app\.activate$", r"^actions\.", r"^AX_(STATUS|PROMPT)$", ] SEQ_HIGH_SIGNAL_RE = re.compile("|".join(f"(?:{p})" for p in SEQ_HIGH_SIGNAL_PATTERNS)) LONG_TOKEN_RE = re.compile(r"\b[A-Za-z0-9_\-]{32,}\b") @dataclass class DatasetRow: id: str source: str event_name: str at_ms: int success: bool duration_ms: int error_class: str record: dict[str, Any] def _read_jsonl(path: Path, *, last: int = 0) -> list[dict[str, Any]]: if not path.exists(): return [] lines = path.read_text(encoding="utf-8", errors="replace").splitlines() if last > 0: lines = lines[-last:] out: list[dict[str, Any]] = [] for line in lines: line = line.strip() if not line: continue try: payload = json.loads(line) except json.JSONDecodeError: continue if isinstance(payload, dict): out.append(payload) return out def _now_stamp() -> str: return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") def _hash_id(parts: list[str]) -> str: joined = "||".join(parts) return hashlib.sha256(joined.encode("utf-8")).hexdigest() def _bucket(row_id: str, seed: int) -> int: digest = hashlib.sha256(f"{seed}:{row_id}".encode("utf-8")).hexdigest() return int(digest[:8], 16) % 100 def _as_int(value: Any, default: int = 0) -> int: if isinstance(value, bool): return default if isinstance(value, int): return value if isinstance(value, float) and value.is_integer(): return int(value) return default def _sanitize_text(value: Any) -> str: if not isinstance(value, str): return "" text = value.strip() text = LONG_TOKEN_RE.sub("[REDACTED]", text) return text def _extract_captured_text(value: Any) -> str: if isinstance(value, dict): text = value.get("text") if isinstance(text, str): return _sanitize_text(text) return "" return _sanitize_text(value) def _reward_components(success: bool, duration_ms: int) -> tuple[float, float, float]: success_score = 1.0 if success else 0.0 # Keep this bounded and simple for initial training signal. efficiency = max(0.0, 1.0 - (min(duration_ms, 20_000) / 20_000.0)) composite = (0.8 * success_score) + (0.2 * efficiency) return round(success_score, 6), round(efficiency, 6), round(composite, 6) def _normalize_flow(rows: list[dict[str, Any]]) -> list[DatasetRow]: out: list[DatasetRow] = [] for idx, row in enumerate(rows, start=1): event_type = str(row.get("event_type") or "") if not event_type.startswith("everruns."): continue stage = str(row.get("stage") or "") event_name = f"everruns.stage.{stage}" if event_type == "everruns.runtime_event" and stage else event_type session_id = str(row.get("session_id") or "") event_id = str(row.get("event_id") or f"flow-event-{idx}") ts_ms = max(0, _as_int(row.get("ts_unix_ms"), 0)) duration_ms = max(0, _as_int(row.get("duration_ms"), 0)) success = bool(row.get("ok", True)) error_class = _sanitize_text(row.get("error_class")) if event_type == "everruns.qa_pair": prompt = _extract_captured_text(row.get("prompt_text")) response = _extract_captured_text(row.get("response_text")) if not prompt or not response: continue success_score, efficiency_score, composite = _reward_components(success, duration_ms) stable_id = _hash_id( [ "flow_qa", session_id, event_id, str(ts_ms), prompt[:256], response[:256], ] ) record = { "record_type": "assistant_sft_example", "id": stable_id, "source": "flow_rl_signals", "event_name": event_name, "at_ms": ts_ms, "success": success, "duration_ms": duration_ms, "error_class": error_class, "session_id": session_id, "prompt": prompt, "response": response, "reward_components": { "success": success_score, "efficiency": efficiency_score, }, "reward_composite": composite, "metadata": { "runtime": str(row.get("runtime") or ""), "input_message_id": _sanitize_text(row.get("input_message_id")), "event_id": event_id, }, } out.append( DatasetRow( id=stable_id, source="flow_rl_signals", event_name=event_name, at_ms=ts_ms, success=success, duration_ms=duration_ms, error_class=error_class, record=record, ) ) continue stable_id = _hash_id(["flow", session_id, event_id, event_name, str(ts_ms)]) success_score, efficiency_score, composite = _reward_components(success, duration_ms) record = { "record_type": "runtime_training_event", "id": stable_id, "source": "flow_rl_signals", "event_name": event_name, "at_ms": ts_ms, "success": success, "duration_ms": duration_ms, "error_class": error_class, "session_id": session_id, "reward_components": { "success": success_score, "efficiency": efficiency_score, }, "reward_composite": composite, "metadata": { "runtime": str(row.get("runtime") or ""), "tool_call_id": _sanitize_text(row.get("tool_call_id")), "tool_name": _sanitize_text(row.get("tool_name")), "seq_op": _sanitize_text(row.get("seq_op")), "attrs": row.get("attrs", {}), }, } out.append( DatasetRow( id=stable_id, source="flow_rl_signals", event_name=event_name, at_ms=ts_ms, success=success, duration_ms=duration_ms, error_class=error_class, record=record, ) ) return out def _normalize_seq(rows: list[dict[str, Any]]) -> list[DatasetRow]: out: list[DatasetRow] = [] for idx, row in enumerate(rows, start=1): name = str(row.get("name") or row.get("event") or row.get("kind") or "") if not name: continue if name == "agent.qa.pair": subject_raw = row.get("subject") subject_obj: dict[str, Any] = {} if isinstance(subject_raw, str): try: parsed = json.loads(subject_raw) if isinstance(parsed, dict): subject_obj = parsed except json.JSONDecodeError: subject_obj = {} elif isinstance(subject_raw, dict): subject_obj = subject_raw prompt = _sanitize_text(subject_obj.get("question")) response = _sanitize_text(subject_obj.get("answer")) if not prompt or not response: continue event_id = str(row.get("event_id") or f"seq-event-{idx}") session_id = str(row.get("session_id") or subject_obj.get("session_id") or "") ts_ms = max(0, _as_int(row.get("ts_ms"), 0)) dur_us = max(0, _as_int(row.get("dur_us"), 0)) duration_ms = dur_us // 1000 success = bool(row.get("ok", True)) error_class = "" stable_id = _hash_id( [ "seq_qa", session_id, event_id, str(ts_ms), prompt[:256], response[:256], ] ) success_score, efficiency_score, composite = _reward_components(success, duration_ms) record = { "record_type": "assistant_sft_example", "id": stable_id, "source": "seq_mem", "event_name": name, "at_ms": ts_ms, "success": success, "duration_ms": duration_ms, "error_class": error_class, "session_id": session_id, "prompt": prompt, "response": response, "reward_components": { "success": success_score, "efficiency": efficiency_score, }, "reward_composite": composite, "metadata": { "agent": _sanitize_text(subject_obj.get("agent")), "project_path": _sanitize_text(subject_obj.get("project_path")), "source_path": _sanitize_text(subject_obj.get("source_path")), "line_offset": _as_int(subject_obj.get("offset"), 0), }, } out.append( DatasetRow( id=stable_id, source="seq_mem", event_name=name, at_ms=ts_ms, success=success, duration_ms=duration_ms, error_class=error_class, record=record, ) ) continue if not SEQ_HIGH_SIGNAL_RE.search(name): continue event_id = str(row.get("event_id") or f"seq-event-{idx}") session_id = str(row.get("session_id") or "") ts_ms = max(0, _as_int(row.get("ts_ms"), 0)) dur_us = max(0, _as_int(row.get("dur_us"), 0)) duration_ms = dur_us // 1000 success = bool(row.get("ok", True)) error_class = "" stable_id = _hash_id(["seq", session_id, event_id, name, str(ts_ms)]) success_score, efficiency_score, composite = _reward_components(success, duration_ms) subject = _sanitize_text(row.get("subject")) record = { "record_type": "runtime_training_event", "id": stable_id, "source": "seq_mem", "event_name": name, "at_ms": ts_ms, "success": success, "duration_ms": duration_ms, "error_class": error_class, "session_id": session_id, "reward_components": { "success": success_score, "efficiency": efficiency_score, }, "reward_composite": composite, "metadata": { "event_id": event_id, "subject": subject, "content_hash": _sanitize_text(row.get("content_hash")), }, } out.append( DatasetRow( id=stable_id, source="seq_mem", event_name=name, at_ms=ts_ms, success=success, duration_ms=duration_ms, error_class=error_class, record=record, ) ) return out def _cap_by_event(rows: list[DatasetRow], *, max_per_event: int, seed: int) -> tuple[list[DatasetRow], dict[str, int]]: if max_per_event <= 0: return rows, {} grouped: dict[str, list[DatasetRow]] = defaultdict(list) for row in rows: grouped[row.event_name].append(row) kept: list[DatasetRow] = [] dropped: dict[str, int] = {} for event_name, event_rows in grouped.items(): ranked = sorted( event_rows, key=lambda r: hashlib.sha256(f"{seed}:{r.id}".encode("utf-8")).hexdigest(), ) kept_rows = ranked[:max_per_event] kept.extend(kept_rows) if len(ranked) > max_per_event: dropped[event_name] = len(ranked) - max_per_event kept.sort(key=lambda r: (r.at_ms, r.id)) return kept, dropped def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as fh: for row in rows: fh.write(json.dumps(row, ensure_ascii=True)) fh.write("\n") def _build_report( rows: list[DatasetRow], train: list[DatasetRow], val: list[DatasetRow], test: list[DatasetRow], *, min_rows: int, min_unique_events: int, max_dominance: float, ) -> tuple[dict[str, Any], bool]: errors: list[str] = [] warnings: list[str] = [] total_rows = len(rows) if total_rows < max(1, min_rows): errors.append(f"rows below threshold: {total_rows} < {min_rows}") if not train: errors.append("train split is empty") event_counts = Counter(r.event_name for r in rows) record_type_counts = Counter(str(r.record.get("record_type") or "") for r in rows) sft_only = total_rows > 0 and set(record_type_counts.keys()) <= {"assistant_sft_example"} unique_events = len(event_counts) min_unique_gate = 1 if sft_only else max(1, min_unique_events) if unique_events < min_unique_gate: errors.append(f"unique event names below threshold: {unique_events} < {min_unique_gate}") dominant_name = "" dominant_ratio = 0.0 if event_counts and total_rows > 0: dominant_name, dominant_count = event_counts.most_common(1)[0] dominant_ratio = dominant_count / total_rows dominance_gate = 1.0 if sft_only else max_dominance if dominant_ratio > dominance_gate: errors.append( f"event dominance too high: {dominant_name}={dominant_ratio:.3f} > {dominance_gate:.3f}" ) success_count = sum(1 for r in rows if r.success) success_rate = (success_count / total_rows) if total_rows else 0.0 if total_rows > 0 and (success_rate < 0.05 or success_rate > 0.98): warnings.append(f"success rate skewed: {success_rate:.3f}") report = { "schema_version": "flow_runtime_validation_v1", "generated_at": datetime.now(timezone.utc).isoformat(), "ok": len(errors) == 0, "counts": { "rows": total_rows, "train_rows": len(train), "val_rows": len(val), "test_rows": len(test), "unique_events": unique_events, "success_rate": round(success_rate, 6), "record_types": dict(record_type_counts), "sft_only": sft_only, }, "dominance": { "event_name": dominant_name, "ratio": round(dominant_ratio, 6), }, "errors": errors, "warnings": warnings, } return report, len(errors) == 0 def main() -> int: parser = argparse.ArgumentParser(description="Build RL runtime dataset from flow + seq logs") parser.add_argument("--harbor-dir", default=str(Path("~/repos/laude-institute/harbor").expanduser())) parser.add_argument("--flow-signals", default="out/logs/flow_rl_signals.jsonl") parser.add_argument("--seq-mem", default=str(Path("~/.config/flow/rl/seq_mem.jsonl").expanduser())) parser.add_argument("--snapshot", default="", help="snapshot name; default timestamp") parser.add_argument("--flow-last", type=int, default=20_000) parser.add_argument("--seq-last", type=int, default=50_000) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--val-percent", type=int, default=10) parser.add_argument("--test-percent", type=int, default=10) parser.add_argument("--max-per-event", type=int, default=120) parser.add_argument("--min-rows", type=int, default=50) parser.add_argument("--min-unique-events", type=int, default=3) parser.add_argument("--max-dominance", type=float, default=0.90) parser.add_argument("--write-latest", action="store_true") parser.add_argument("--allow-quality-fail", action="store_true") args = parser.parse_args() harbor_dir = Path(args.harbor_dir).expanduser().resolve() snapshot = args.snapshot.strip() or _now_stamp() flow_rows_raw = _read_jsonl(Path(args.flow_signals).expanduser().resolve(), last=max(0, args.flow_last)) seq_rows_raw = _read_jsonl(Path(args.seq_mem).expanduser().resolve(), last=max(0, args.seq_last)) flow_rows = _normalize_flow(flow_rows_raw) seq_rows = _normalize_seq(seq_rows_raw) merged = flow_rows + seq_rows unique: dict[str, DatasetRow] = {} for row in merged: unique[row.id] = row deduped = list(unique.values()) deduped.sort(key=lambda r: (r.at_ms, r.id)) deduped, dropped_by_event = _cap_by_event( deduped, max_per_event=max(0, args.max_per_event), seed=args.seed, ) val_pct = max(0, min(args.val_percent, 100)) test_pct = max(0, min(args.test_percent, 100 - val_pct)) train_rows: list[DatasetRow] = [] val_rows: list[DatasetRow] = [] test_rows: list[DatasetRow] = [] for row in deduped: b = _bucket(row.id, args.seed) if b < test_pct: test_rows.append(row) elif b < test_pct + val_pct: val_rows.append(row) else: train_rows.append(row) raw_dir = harbor_dir / "data" / "flow_runtime" / snapshot prepared_dir = harbor_dir / "data" / "flow_runtime_prepared" / snapshot _write_jsonl(raw_dir / "events.jsonl", [r.record for r in deduped]) _write_jsonl(prepared_dir / "train.jsonl", [r.record for r in train_rows]) _write_jsonl(prepared_dir / "val.jsonl", [r.record for r in val_rows]) _write_jsonl(prepared_dir / "test.jsonl", [r.record for r in test_rows]) event_counts = Counter(r.event_name for r in deduped) _write_jsonl( prepared_dir / "event_counts.jsonl", [{"event_name": name, "count": count} for name, count in event_counts.most_common()], ) manifest = { "schema_version": "flow_runtime_dataset_v1", "generated_at": datetime.now(timezone.utc).isoformat(), "snapshot": snapshot, "seed": args.seed, "split": {"val_percent": val_pct, "test_percent": test_pct}, "cap": {"max_per_event": max(0, args.max_per_event), "dropped_by_event": dropped_by_event}, "counts": { "flow_rows_raw": len(flow_rows_raw), "seq_rows_raw": len(seq_rows_raw), "flow_rows_mapped": len(flow_rows), "seq_rows_mapped": len(seq_rows), "deduped_rows": len(deduped), "train_rows": len(train_rows), "val_rows": len(val_rows), "test_rows": len(test_rows), }, "paths": { "raw_events": str(raw_dir / "events.jsonl"), "train": str(prepared_dir / "train.jsonl"), "val": str(prepared_dir / "val.jsonl"), "test": str(prepared_dir / "test.jsonl"), "event_counts": str(prepared_dir / "event_counts.jsonl"), }, } (raw_dir / "summary.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") (prepared_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") report, ok = _build_report( deduped, train_rows, val_rows, test_rows, min_rows=max(1, args.min_rows), min_unique_events=max(1, args.min_unique_events), max_dominance=max(0.0, min(args.max_dominance, 1.0)), ) (prepared_dir / "validation_report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") if args.write_latest: latest_raw = harbor_dir / "data" / "flow_runtime" / "latest" latest_prepared = harbor_dir / "data" / "flow_runtime_prepared" / "latest" _write_jsonl(latest_raw / "events.jsonl", [r.record for r in deduped]) _write_jsonl(latest_prepared / "train.jsonl", [r.record for r in train_rows]) _write_jsonl(latest_prepared / "val.jsonl", [r.record for r in val_rows]) _write_jsonl(latest_prepared / "test.jsonl", [r.record for r in test_rows]) _write_jsonl( latest_prepared / "event_counts.jsonl", [{"event_name": name, "count": count} for name, count in event_counts.most_common()], ) (latest_raw / "summary.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") (latest_prepared / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") (latest_prepared / "validation_report.json").write_text( json.dumps(report, indent=2) + "\n", encoding="utf-8", ) print(f"Built flow runtime dataset snapshot: {snapshot}") print(f" flow rows mapped: {len(flow_rows)}") print(f" seq rows mapped: {len(seq_rows)}") print(f" deduped rows: {len(deduped)}") print(f" train/val/test: {len(train_rows)}/{len(val_rows)}/{len(test_rows)}") print(f" quality ok: {ok}") print(f" raw: {raw_dir}") print(f" prepared: {prepared_dir}") if not ok and not args.allow_quality_fail: return 1 return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/cdn-nginx.conf ================================================ # Nginx config for cdn.myflow.sh # Copy to /etc/nginx/sites-available/cdn.myflow.sh # Then: ln -s /etc/nginx/sites-available/cdn.myflow.sh /etc/nginx/sites-enabled/ # And: nginx -t && systemctl reload nginx server { listen 80; server_name cdn.myflow.sh; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name cdn.myflow.sh; # SSL certs (use certbot) ssl_certificate /etc/letsencrypt/live/cdn.myflow.sh/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/cdn.myflow.sh/privkey.pem; root /var/www/cdn.myflow.sh; autoindex on; # Cache static files location / { add_header Cache-Control "public, max-age=31536000"; add_header Access-Control-Allow-Origin "*"; } # Gzip gzip on; gzip_types application/octet-stream application/x-tar; } ================================================ FILE: scripts/check_cli_startup_thresholds.py ================================================ #!/usr/bin/env python3 from __future__ import annotations import argparse import json from pathlib import Path def main() -> int: parser = argparse.ArgumentParser( description="Fail when CLI startup benchmark results exceed repository thresholds." ) parser.add_argument("benchmark_json", help="Path to JSON output from scripts/bench-cli-startup.py") parser.add_argument( "--thresholds", default=str(Path(__file__).with_name("cli_startup_thresholds.json")), help="Path to threshold JSON file", ) args = parser.parse_args() benchmark_path = Path(args.benchmark_json).expanduser() thresholds_path = Path(args.thresholds).expanduser() payload = json.loads(benchmark_path.read_text(encoding="utf-8")) thresholds = json.loads(thresholds_path.read_text(encoding="utf-8")) violations: list[str] = [] results = payload.get("results", {}) for scenario, expected in thresholds.items(): actual = results.get(scenario) if actual is None: violations.append(f"{scenario}: missing from benchmark output") continue for metric, limit in expected.items(): value = actual.get(metric) if value is None: violations.append(f"{scenario}: missing metric {metric}") continue if value > limit: violations.append( f"{scenario}: {metric}={value:.2f}ms exceeds {limit:.2f}ms" ) if violations: print("CLI startup threshold violations:") for violation in violations: print(f" - {violation}") return 1 print("CLI startup thresholds passed.") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/check_release_tag_version.py ================================================ #!/usr/bin/env python3 from __future__ import annotations import pathlib import re import sys def read_package_version(cargo_toml: pathlib.Path) -> str: text = cargo_toml.read_text(encoding="utf-8") in_package = False for raw_line in text.splitlines(): line = raw_line.strip() if line.startswith("["): in_package = line == "[package]" continue if not in_package: continue match = re.match(r'version\s*=\s*"([^"]+)"', line) if match: return match.group(1) raise RuntimeError(f"failed to find [package].version in {cargo_toml}") def main(argv: list[str]) -> int: if len(argv) != 2: print("usage: check_release_tag_version.py <tag>", file=sys.stderr) return 2 tag = argv[1] if not tag.startswith("v"): print(f"error: expected release tag like vX.Y.Z, got {tag}", file=sys.stderr) return 1 root = pathlib.Path(__file__).resolve().parent.parent version = read_package_version(root / "Cargo.toml") expected_tag = f"v{version}" if tag != expected_tag: print( f"error: release tag {tag} does not match Cargo.toml version {version} " f"(expected {expected_tag})", file=sys.stderr, ) return 1 print(f"verified: release tag {tag} matches Cargo.toml version {version}") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv)) ================================================ FILE: scripts/ci/check-readme-case.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" # Enforce lowercase readme filenames in tracked files. bad="$(git ls-files | rg '(^|/)README\.md$' || true)" if [[ -n "$bad" ]]; then echo "error: uppercase README.md paths are not allowed; use lowercase readme.md" echo "$bad" | sed 's/^/ - /' exit 1 fi echo "ok: no uppercase README.md paths found" ================================================ FILE: scripts/ci_blacksmith.py ================================================ #!/usr/bin/env python3 """ Toggle CI workflows between runner profiles. Profiles: - github: Default GitHub-hosted Linux jobs, SIMD lane disabled. - blacksmith: Blacksmith Linux jobs, SIMD lane enabled on Blacksmith. - host: GitHub Linux jobs, SIMD lane enabled on ci.1focus.ai self-hosted runner. Usage: python3 scripts/ci_blacksmith.py status python3 scripts/ci_blacksmith.py enable python3 scripts/ci_blacksmith.py enable --commit --push python3 scripts/ci_blacksmith.py host python3 scripts/ci_blacksmith.py disable """ from __future__ import annotations import argparse import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] WORKFLOWS = [ ROOT / ".github" / "workflows" / "canary.yml", ROOT / ".github" / "workflows" / "release.yml", ] GITHUB_X64 = "ubuntu-latest" GITHUB_ARM = "ubuntu-latest" BLACKSMITH_X64 = "blacksmith-2vcpu-ubuntu-2404" BLACKSMITH_ARM = "blacksmith-2vcpu-ubuntu-2404-arm" BLACKSMITH_SIMD = "blacksmith-4vcpu-ubuntu-2404" HOST_SIMD = "[self-hosted, linux, x64, ci-1focus]" WORKFLOW_REL_PATHS = [ ".github/workflows/canary.yml", ".github/workflows/release.yml", ] def rewrite_workflow(path: Path, mode: str) -> bool: content = path.read_text(encoding="utf-8") original = content if mode == "blacksmith": linux_x64 = BLACKSMITH_X64 linux_arm = BLACKSMITH_ARM simd_runs_on = BLACKSMITH_SIMD simd_if_line = "" elif mode == "host": linux_x64 = GITHUB_X64 linux_arm = GITHUB_ARM simd_runs_on = HOST_SIMD simd_if_line = "" else: linux_x64 = GITHUB_X64 linux_arm = GITHUB_ARM simd_runs_on = "ubuntu-latest" simd_if_line = " if: ${{ false }}\n" content = re.sub( r"(- target: x86_64-unknown-linux-gnu\s*\n\s*os:\s*)([^\n]+)", rf"\1{linux_x64}", content, count=1, ) content = re.sub( r"(- target: aarch64-unknown-linux-gnu\s*\n\s*os:\s*)([^\n]+)", rf"\1{linux_arm}", content, count=1, ) simd_block = re.compile( r"(^ build-linux-host-simd:\n)(?P<body>(?:^ .*\n)*)", re.MULTILINE, ) block_match = simd_block.search(content) if block_match: body_lines = block_match.group("body").splitlines(keepends=True) rewritten_body: list[str] = [] has_runs_on = False for line in body_lines: if re.match(r"^ if:", line): continue if re.match(r"^ runs-on:", line): rewritten_body.append(f" runs-on: {simd_runs_on}\n") has_runs_on = True continue rewritten_body.append(line) if not has_runs_on: rewritten_body.insert(0, f" runs-on: {simd_runs_on}\n") if simd_if_line: rewritten_body.insert(0, simd_if_line) replacement = block_match.group(1) + "".join(rewritten_body) content = ( content[: block_match.start()] + replacement + content[block_match.end() :] ) changed = content != original if changed: path.write_text(content, encoding="utf-8") return changed def detect_profile(path: Path) -> str: content = path.read_text(encoding="utf-8") if BLACKSMITH_X64 in content and BLACKSMITH_ARM in content: return "blacksmith" if HOST_SIMD in content and "if: ${{ false }}" not in content: return "host" if GITHUB_X64 in content and "blacksmith-" not in content: return "github" if GITHUB_X64 in content and GITHUB_ARM in content: return "github" return "mixed" def status() -> int: all_ok = True for wf in WORKFLOWS: profile = detect_profile(wf) print(f"{wf.relative_to(ROOT)}: {profile}") if profile == "mixed": all_ok = False if not all_ok: print("Detected mixed workflow state; run enable or disable to normalize.") return 1 return 0 def run_cmd(args: list[str]) -> None: subprocess.run(args, cwd=ROOT, check=True) def has_staged_workflow_changes() -> bool: result = subprocess.run( ["git", "diff", "--cached", "--quiet", "--", *WORKFLOW_REL_PATHS], cwd=ROOT, check=False, ) return result.returncode != 0 def maybe_commit_and_push(mode: str, commit: bool, push: bool) -> int: if push and not commit: print("--push requires --commit", file=sys.stderr) return 2 if not commit: return 0 run_cmd(["git", "add", *WORKFLOW_REL_PATHS]) if not has_staged_workflow_changes(): print("No workflow changes to commit.") return 0 run_cmd(["git", "commit", "-m", f"ci: switch workflows to {mode} runners"]) print("Committed workflow changes.") if push: run_cmd(["git", "push", "origin", "HEAD"]) print("Pushed commit.") return 0 def set_mode(mode: str, commit: bool, push: bool) -> int: if push and not commit: print("--push requires --commit", file=sys.stderr) return 2 changed_any = False for wf in WORKFLOWS: changed = rewrite_workflow(wf, mode=mode) changed_any = changed_any or changed state = "updated" if changed else "unchanged" print(f"{wf.relative_to(ROOT)}: {state}") if changed_any: print(f"CI runner mode set to: {mode}") else: print(f"CI runner mode already set to: {mode}") return maybe_commit_and_push(mode=mode, commit=commit, push=push) def main() -> int: parser = argparse.ArgumentParser(description="Manage CI runner profile.") parser.add_argument( "command", choices=["status", "enable", "disable", "host"], help="status | enable (Blacksmith) | host (self-hosted SIMD lane) | disable (GitHub-hosted)", ) parser.add_argument( "--commit", action="store_true", help="Commit workflow changes after rewriting files", ) parser.add_argument( "--push", action="store_true", help="Push the commit (requires --commit)", ) args = parser.parse_args() if args.command == "status": return status() if args.command == "enable": return set_mode(mode="blacksmith", commit=args.commit, push=args.push) if args.command == "host": return set_mode(mode="host", commit=args.commit, push=args.push) if args.command == "disable": return set_mode(mode="github", commit=args.commit, push=args.push) return 2 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/ci_host_runner.py ================================================ #!/usr/bin/env python3 """ Provision and manage a self-hosted GitHub Actions runner on the configured infra Linux host. This script intentionally uses: - `infra host show` for host resolution (no ad-hoc env vars) - `gh api` for runner registration/remove tokens Usage: python3 scripts/ci_host_runner.py status python3 scripts/ci_host_runner.py install --repo nikivdev/flow python3 scripts/ci_host_runner.py remove --repo nikivdev/flow """ from __future__ import annotations import argparse import json import re import shlex import subprocess import sys import time from dataclasses import dataclass DEFAULT_REPO = "nikivdev/flow" DEFAULT_LABELS = "ci-1focus,linux,x64" DEFAULT_RUNNER_DIR = "/opt/actions-runner" @dataclass class HostTriplet: user: str host: str port: str def run_capture(args: list[str], cwd: str | None = None) -> str: result = subprocess.run( args, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) return result.stdout.strip() def run_stream(args: list[str], *, input_text: str | None = None) -> None: subprocess.run(args, check=True, text=True, input=input_text) def load_host_triplet() -> HostTriplet: shown = run_capture(["infra", "host", "show"]) match = re.search(r"Linux\s+host:\s*([^@\s]+)@([^:\s]+):(\d+)", shown) if not match: raise SystemExit( "Unable to parse infra host config. Run: infra host set <user@ip>" ) return HostTriplet(user=match.group(1), host=match.group(2), port=match.group(3)) def gh_api(path: str, *, method: str = "GET", jq: str | None = None) -> str: cmd = ["gh", "api"] if method != "GET": cmd += ["-X", method] cmd += [path] if jq: cmd += ["--jq", jq] return run_capture(cmd) def gh_api_json(path: str, *, method: str = "GET") -> dict: out = gh_api(path, method=method) return json.loads(out) if out else {} def ssh_script(host: HostTriplet, script: str) -> None: ssh_target = f"{host.user}@{host.host}" cmd = [ "ssh", "-p", host.port, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", ssh_target, "bash", "-s", ] run_stream(cmd, input_text=script) def ssh_capture(host: HostTriplet, script: str) -> str: ssh_target = f"{host.user}@{host.host}" cmd = [ "ssh", "-p", host.port, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", ssh_target, "bash", "-s", ] result = subprocess.run( cmd, check=True, text=True, input=script, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) return result.stdout.strip() def shell_assign(name: str, value: str) -> str: return f"{name}={shlex.quote(value)}" def default_runner_name(host: HostTriplet) -> str: safe_host = re.sub(r"[^a-zA-Z0-9-]", "-", host.host) return f"ci-1focus-{safe_host}" def github_runner_state(repo: str, runner_name: str) -> tuple[str, bool | None]: payload = gh_api_json(f"repos/{repo}/actions/runners") for runner in payload.get("runners", []): if runner.get("name") == runner_name: return str(runner.get("status", "unknown")), bool(runner.get("busy", False)) return "missing", None def host_service_state(host: HostTriplet) -> str: script = r''' set -euo pipefail state="$(systemctl is-active 'actions.runner.*' 2>/dev/null || true)" if echo "$state" | grep -q '^active$'; then echo "active" elif echo "$state" | grep -Eq '^(inactive|failed|activating|deactivating)$'; then echo "${state%%$'\n'*}" elif systemctl list-unit-files 'actions.runner.*' 2>/dev/null | grep -q 'actions.runner.'; then echo "inactive" else echo "missing" fi ''' return ssh_capture(host, script).strip() def cmd_status(args: argparse.Namespace) -> int: host = load_host_triplet() print(f"Host: {host.user}@{host.host}:{host.port}") remote_status = r''' set -euo pipefail status_out="$(systemctl --no-pager --full status 'actions.runner.*' 2>/dev/null || true)" if [[ -n "${status_out}" ]]; then printf '%s\n' "${status_out}" | sed -n '1,60p' else echo "No GitHub Actions runner service is installed on this host." fi ''' ssh_script(host, remote_status) repo = args.repo print(f"\nGitHub runners for {repo} (label: ci-1focus):") out = gh_api( f"repos/{repo}/actions/runners", jq='[.runners[] | select(any(.labels[]; .name == "ci-1focus")) | "\\(.name)\\t\\(.status)\\tbusy=\\(.busy)"] | .[]?', ) if out: print(out) else: print("No runners with label ci-1focus found.") return 0 def cmd_health(args: argparse.Namespace) -> int: host = load_host_triplet() runner_name = args.runner_name or default_runner_name(host) service = host_service_state(host) gh_status, busy = github_runner_state(args.repo, runner_name) busy_str = "n/a" if busy is None else ("true" if busy else "false") print( f"runner={runner_name} host_service={service} github_status={gh_status} busy={busy_str}" ) return 0 if service == "active" and gh_status == "online" else 1 def cmd_wait_online(args: argparse.Namespace) -> int: host = load_host_triplet() runner_name = args.runner_name or default_runner_name(host) deadline = time.time() + max(1, args.timeout_secs) interval = max(1, args.interval_secs) while time.time() <= deadline: service = host_service_state(host) gh_status, busy = github_runner_state(args.repo, runner_name) busy_str = "n/a" if busy is None else ("true" if busy else "false") print( f"waiting: runner={runner_name} host_service={service} github_status={gh_status} busy={busy_str}" ) if service == "active" and gh_status == "online": return 0 time.sleep(interval) print( f"Timed out waiting for runner to become online: {runner_name}", file=sys.stderr, ) return 1 def cmd_install(args: argparse.Namespace) -> int: host = load_host_triplet() repo = args.repo labels = args.labels runner_name = args.runner_name or default_runner_name(host) version = args.version if not version: latest = gh_api("repos/actions/runner/releases/latest", jq=".tag_name") version = latest.lstrip("v") registration_token = gh_api( f"repos/{repo}/actions/runners/registration-token", method="POST", jq=".token", ) remove_token = gh_api( f"repos/{repo}/actions/runners/remove-token", method="POST", jq=".token", ) setup_script = f''' set -euo pipefail {shell_assign("RUNNER_DIR", DEFAULT_RUNNER_DIR)} {shell_assign("REPO", repo)} {shell_assign("VERSION", version)} {shell_assign("RUNNER_NAME", runner_name)} {shell_assign("LABELS", labels)} {shell_assign("REGISTRATION_TOKEN", registration_token)} {shell_assign("REMOVE_TOKEN", remove_token)} if [ "$(id -u)" -eq 0 ]; then SUDO="" RUNNER_USER_CMD="runuser -u gha-runner --" else if ! command -v sudo >/dev/null 2>&1; then echo "sudo is required for non-root execution on the host" >&2 exit 1 fi SUDO="sudo" RUNNER_USER_CMD="sudo -u gha-runner" fi if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update -y $SUDO apt-get install -y curl ca-certificates tar fi if ! id -u gha-runner >/dev/null 2>&1; then $SUDO useradd --create-home --home-dir /home/gha-runner --shell /bin/bash gha-runner fi $SUDO mkdir -p "$RUNNER_DIR" $SUDO chown -R gha-runner:gha-runner "$RUNNER_DIR" cd "$RUNNER_DIR" CURRENT_VERSION="" if [ -f .runner_version ]; then CURRENT_VERSION="$(cat .runner_version || true)" fi if [ ! -x ./config.sh ] || [ "$CURRENT_VERSION" != "$VERSION" ]; then rm -rf "$RUNNER_DIR"/* curl -fsSL -o actions-runner.tar.gz "https://github.com/actions/runner/releases/download/v${{VERSION}}/actions-runner-linux-x64-${{VERSION}}.tar.gz" tar xzf actions-runner.tar.gz rm -f actions-runner.tar.gz echo "$VERSION" > .runner_version $SUDO chown -R gha-runner:gha-runner "$RUNNER_DIR" fi # Ensure re-install is idempotent: service must be removed before reconfiguration. if [ -x ./svc.sh ]; then ./svc.sh stop || true ./svc.sh uninstall || true fi if [ -f .runner ]; then $RUNNER_USER_CMD env RUNNER_DIR="$RUNNER_DIR" REMOVE_TOKEN="$REMOVE_TOKEN" \ bash -lc 'cd "$RUNNER_DIR" && ./config.sh remove --token "$REMOVE_TOKEN" || true' fi $RUNNER_USER_CMD env RUNNER_DIR="$RUNNER_DIR" REPO="$REPO" REGISTRATION_TOKEN="$REGISTRATION_TOKEN" RUNNER_NAME="$RUNNER_NAME" LABELS="$LABELS" \ bash -lc 'cd "$RUNNER_DIR" && ./config.sh --url "https://github.com/$REPO" --token "$REGISTRATION_TOKEN" --name "$RUNNER_NAME" --labels "$LABELS" --work _work --unattended --replace' cd "$RUNNER_DIR" if [ -x ./svc.sh ]; then ./svc.sh install gha-runner || true ./svc.sh start fi systemctl --no-pager --full status 'actions.runner.*' | sed -n '1,60p' || true ''' print(f"Installing runner on {host.user}@{host.host}:{host.port}") print(f"Repo: {repo}") print(f"Runner name: {runner_name}") print(f"Labels: {labels}") print(f"Runner version: {version}") ssh_script(host, setup_script) return 0 def cmd_remove(args: argparse.Namespace) -> int: host = load_host_triplet() repo = args.repo remove_token = gh_api( f"repos/{repo}/actions/runners/remove-token", method="POST", jq=".token", ) purge = "1" if args.purge else "0" remove_script = f''' set -euo pipefail {shell_assign("RUNNER_DIR", DEFAULT_RUNNER_DIR)} {shell_assign("REMOVE_TOKEN", remove_token)} {shell_assign("PURGE", purge)} if [ "$(id -u)" -eq 0 ]; then SUDO="" RUNNER_USER_CMD="runuser -u gha-runner --" else if ! command -v sudo >/dev/null 2>&1; then echo "sudo is required for non-root execution on the host" >&2 exit 1 fi SUDO="sudo" RUNNER_USER_CMD="sudo -u gha-runner" fi if [ ! -d "$RUNNER_DIR" ]; then echo "Runner directory not found: $RUNNER_DIR" exit 0 fi cd "$RUNNER_DIR" if [ -x ./svc.sh ]; then ./svc.sh stop || true fi if [ -f .runner ] && [ -x ./config.sh ]; then $RUNNER_USER_CMD env RUNNER_DIR="$RUNNER_DIR" REMOVE_TOKEN="$REMOVE_TOKEN" \ bash -lc 'cd "$RUNNER_DIR" && ./config.sh remove --token "$REMOVE_TOKEN" || true' fi if [ -x ./svc.sh ]; then ./svc.sh uninstall || true fi if [ "$PURGE" = "1" ]; then cd / $SUDO rm -rf "$RUNNER_DIR" $SUDO userdel -r gha-runner || true echo "Runner files and gha-runner user removed." else echo "Runner unregistered and service removed (files kept)." fi ''' print(f"Removing runner from {host.user}@{host.host}:{host.port}") ssh_script(host, remove_script) return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Manage ci.1focus.ai host GitHub runner") sub = parser.add_subparsers(dest="command", required=True) status = sub.add_parser("status", help="Show remote service status + GitHub runner status") status.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repo in owner/name format") status.set_defaults(handler=cmd_status) install = sub.add_parser("install", help="Install/register runner on configured infra Linux host") install.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repo in owner/name format") install.add_argument("--runner-name", default="", help="Runner name override") install.add_argument("--labels", default=DEFAULT_LABELS, help="Comma-separated runner labels") install.add_argument("--version", default="", help="actions/runner version (default: latest)") install.set_defaults(handler=cmd_install) remove = sub.add_parser("remove", help="Unregister runner and remove service") remove.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repo in owner/name format") remove.add_argument("--purge", action="store_true", help="Also delete runner files and gha-runner user") remove.set_defaults(handler=cmd_remove) health = sub.add_parser("health", help="Machine-friendly runner health check") health.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repo in owner/name format") health.add_argument("--runner-name", default="", help="Runner name override") health.set_defaults(handler=cmd_health) wait_online = sub.add_parser("wait-online", help="Wait until runner is active and GitHub reports online") wait_online.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repo in owner/name format") wait_online.add_argument("--runner-name", default="", help="Runner name override") wait_online.add_argument("--timeout-secs", type=int, default=120, help="Maximum wait time") wait_online.add_argument("--interval-secs", type=int, default=5, help="Polling interval") wait_online.set_defaults(handler=cmd_wait_online) return parser def main() -> int: parser = build_parser() args = parser.parse_args() try: return int(args.handler(args)) except subprocess.CalledProcessError as exc: if exc.stderr: print(exc.stderr.strip(), file=sys.stderr) return exc.returncode or 1 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/ci_host_setup.sh ================================================ #!/usr/bin/env bash set -euo pipefail REPO="${FLOW_CI_REPO:-nikivdev/flow}" HOST_TARGET="${1:-}" FORCE_REINSTALL="${FLOW_CI_FORCE_REINSTALL:-0}" WAIT_SECS="${FLOW_CI_WAIT_SECS:-120}" if [[ "${HOST_TARGET}" == "-h" || "${HOST_TARGET}" == "--help" ]]; then cat <<'EOF' Usage: f ci-host-setup [user@ip] One-command setup for Flow CI host mode: 1) Optionally set infra host (if user@ip is provided) 2) Install/register ci-1focus self-hosted GitHub runner 3) Switch workflows to host runner mode (commit + push) 4) Print final runner status Env toggles: FLOW_CI_FORCE_REINSTALL=1 Force reinstall even if runner is healthy FLOW_CI_WAIT_SECS=180 Wait timeout for GitHub online status (default 120) EOF exit 0 fi if ! command -v gh >/dev/null 2>&1; then echo "gh CLI is required (install GitHub CLI first)." >&2 exit 1 fi if ! command -v infra >/dev/null 2>&1; then echo "infra CLI is required (install infra first)." >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 is required." >&2 exit 1 fi if [[ -n "${HOST_TARGET}" ]]; then echo "Configuring infra host: ${HOST_TARGET}" infra host set "${HOST_TARGET}" else if ! infra host show >/dev/null 2>&1; then echo "No infra host configured. Run: f ci-host-setup <user@ip>" >&2 exit 1 fi fi echo "Checking GitHub auth..." gh auth status >/dev/null if python3 ./scripts/ci_host_runner.py health --repo "${REPO}" >/dev/null 2>&1 && [[ "${FORCE_REINSTALL}" != "1" ]]; then echo "Runner already healthy; skipping reinstall. Set FLOW_CI_FORCE_REINSTALL=1 to force." else echo "Installing/registering ci-1focus runner..." attempts=0 max_attempts=2 until python3 ./scripts/ci_host_runner.py install --repo "${REPO}"; do attempts=$((attempts + 1)) if [[ $attempts -ge $max_attempts ]]; then echo "Runner installation failed after ${max_attempts} attempts." >&2 exit 1 fi echo "Retrying runner installation (${attempts}/${max_attempts})..." sleep 3 done fi echo "Waiting for runner to report online..." python3 ./scripts/ci_host_runner.py wait-online --repo "${REPO}" --timeout-secs "${WAIT_SECS}" --interval-secs 5 echo "Switching workflows to host mode (commit + push)..." python3 ./scripts/ci_blacksmith.py host --commit --push echo "Final runner health:" python3 ./scripts/ci_host_runner.py health --repo "${REPO}" echo "Final runner status:" python3 ./scripts/ci_host_runner.py status --repo "${REPO}" || true ================================================ FILE: scripts/cli_startup_thresholds.json ================================================ { "help": { "p50_ms": 60.0, "p95_ms": 100.0 }, "help_full": { "p50_ms": 80.0, "p95_ms": 140.0 }, "info": { "p50_ms": 80.0, "p95_ms": 150.0 }, "projects": { "p50_ms": 80.0, "p95_ms": 150.0 }, "analytics_status": { "p50_ms": 100.0, "p95_ms": 180.0 }, "tasks_list": { "p50_ms": 120.0, "p95_ms": 220.0 }, "tasks_dupes": { "p50_ms": 120.0, "p95_ms": 220.0 }, "deploy_show_host": { "p50_ms": 80.0, "p95_ms": 150.0 } } ================================================ FILE: scripts/codex-flow-wrapper ================================================ #!/usr/bin/env python3 from __future__ import annotations import json import os import signal import subprocess import sys from pathlib import Path RUNTIME_PREFIX = "flow-runtime-" def real_codex_bin() -> str: value = os.environ.get("FLOW_CODEX_REAL_BIN", "").strip() return value or "codex" def agents_skill_root() -> Path: return Path.home() / ".agents" / "skills" def load_runtime_state() -> dict | None: raw_path = os.environ.get("FLOW_CODEX_RUNTIME_STATE", "").strip() if not raw_path: return None path = Path(raw_path).expanduser() if not path.is_file(): return None return json.loads(path.read_text(encoding="utf-8")) def remove_path(path: Path) -> None: try: if path.is_symlink() or path.is_file(): path.unlink() elif path.is_dir(): for child in path.iterdir(): remove_path(child) path.rmdir() except FileNotFoundError: pass def materialize_runtime_skills(state: dict) -> list[Path]: token = str(state.get("token", "")).strip() skills = state.get("skills", []) if not token or not isinstance(skills, list) or not skills: return [] root = agents_skill_root() root.mkdir(parents=True, exist_ok=True) created: list[Path] = [] for skill in skills: if not isinstance(skill, dict): continue name = str(skill.get("name", "")).strip() source = str(skill.get("path", "")).strip() if not name or not source: continue source_path = Path(source).expanduser() if not source_path.is_dir(): continue target = root / name if target.exists() or target.is_symlink(): remove_path(target) os.symlink(source_path, target, target_is_directory=True) created.append(target) return created def cleanup_runtime_symlinks(paths: list[Path]) -> None: for path in paths: remove_path(path) def main() -> int: state = load_runtime_state() created = materialize_runtime_skills(state) if state else [] env = dict(os.environ) runtime_state_path = env.get("FLOW_CODEX_RUNTIME_STATE", "").strip() if runtime_state_path: env["FLOW_CODEX_RUNTIME_STATE_PATH"] = runtime_state_path env.pop("FLOW_CODEX_RUNTIME_STATE", None) proc = None def forward_signal(signum: int, _frame) -> None: nonlocal proc if proc is not None: proc.send_signal(signum) for signum in (signal.SIGINT, signal.SIGTERM): signal.signal(signum, forward_signal) try: proc = subprocess.Popen([real_codex_bin(), *sys.argv[1:]], env=env) return proc.wait() finally: cleanup_runtime_symlinks(created) if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/codex-jazz-wrapper ================================================ #!/usr/bin/env bash set -euo pipefail # Shared Codex wrapper for Flow-managed repos. # Keep stdio untouched so `app-server` JSON-RPC works exactly like raw codex. # # Optional env: # - CODEX_REAL_BIN: absolute path to the real codex binary. # - CODEX_JAZZ_HOOK: executable hook invoked asynchronously as: # <hook> <real-codex-bin> <original-args...> # Hook output is suppressed and never affects review flow. SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" resolve_real_codex() { if [ -n "${CODEX_REAL_BIN:-}" ]; then printf '%s\n' "$CODEX_REAL_BIN" return 0 fi while IFS= read -r candidate; do [ -z "$candidate" ] && continue if [ "$candidate" != "$SELF_PATH" ]; then printf '%s\n' "$candidate" return 0 fi done < <(which -a codex 2>/dev/null || true) return 1 } if ! REAL_CODEX_BIN="$(resolve_real_codex)"; then echo "codex-jazz-wrapper: could not resolve real codex binary; set CODEX_REAL_BIN" >&2 exit 127 fi if [ -n "${CODEX_JAZZ_HOOK:-}" ] && [ -x "${CODEX_JAZZ_HOOK}" ]; then "${CODEX_JAZZ_HOOK}" "${REAL_CODEX_BIN}" "$@" >/dev/null 2>&1 || true & fi exec "${REAL_CODEX_BIN}" "$@" ================================================ FILE: scripts/codex-skill-eval-launchd.py ================================================ #!/usr/bin/env python3 import argparse import os import plistlib import shutil import subprocess import sys from pathlib import Path LABEL = "dev.nikiv.flow-codex-skill-eval" def run(cmd: list[str]) -> subprocess.CompletedProcess: return subprocess.run(cmd, text=True, capture_output=True, check=False) def resolve_f_bin(repo_root: Path) -> str: env_override = os.environ.get("FLOW_CODEX_SKILL_EVAL_F_BIN", "").strip() if env_override: return env_override which_f = shutil.which("f") if which_f: return which_f for candidate in [ repo_root / "target" / "release" / "f", repo_root / "target" / "debug" / "f", ]: if candidate.exists(): return str(candidate) raise SystemExit("Could not resolve f binary. Build flow first or set FLOW_CODEX_SKILL_EVAL_F_BIN.") def plist_path() -> Path: return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist" def domain_target() -> str: return f"gui/{os.getuid()}/{LABEL}" def log_dir() -> Path: path = Path.home() / ".flow" / "logs" path.mkdir(parents=True, exist_ok=True) return path def install( repo_root: Path, minutes: int, limit: int, max_targets: int, within_hours: int, dry_run: bool, ) -> int: if minutes < 5: raise SystemExit("--minutes must be at least 5") if limit < 1 or max_targets < 1 or within_hours < 1: raise SystemExit("--limit, --max-targets, and --within-hours must be positive") f_bin = resolve_f_bin(repo_root) p = plist_path() p.parent.mkdir(parents=True, exist_ok=True) logs = log_dir() payload = { "Label": LABEL, "ProgramArguments": [ f_bin, "codex", "skill-eval", "cron", "--limit", str(limit), "--max-targets", str(max_targets), "--within-hours", str(within_hours), ], "RunAtLoad": True, "StartInterval": minutes * 60, "ProcessType": "Background", "StandardOutPath": str(logs / "codex-skill-eval.launchd.stdout.log"), "StandardErrorPath": str(logs / "codex-skill-eval.launchd.stderr.log"), "EnvironmentVariables": { "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", }, } if dry_run: print(f"plist: {p}") print(f"f_bin: {f_bin}") print(f"every: {minutes} minutes") print(f"limit: {limit}") print(f"max_targets: {max_targets}") print(f"within_hours: {within_hours}") print(plistlib.dumps(payload).decode("utf-8"), end="") return 0 with p.open("wb") as f: plistlib.dump(payload, f) run(["launchctl", "bootout", f"gui/{os.getuid()}", str(p)]) b = run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(p)]) if b.returncode != 0: print(b.stderr.strip(), file=sys.stderr) return b.returncode run(["launchctl", "kickstart", "-k", domain_target()]) print(f"loaded: {domain_target()}") print(f"plist: {p}") print(f"f_bin: {f_bin}") print(f"every: {minutes} minutes") print(f"limit: {limit}") print(f"max_targets: {max_targets}") print(f"within_hours: {within_hours}") return 0 def uninstall() -> int: p = plist_path() run(["launchctl", "bootout", f"gui/{os.getuid()}", str(p)]) if p.exists(): p.unlink() print(f"unloaded: {domain_target()}") print(f"removed: {p}") return 0 def status() -> int: out = run(["launchctl", "print", domain_target()]) if out.returncode != 0: print(f"{domain_target()}: not loaded") if out.stderr.strip(): print(out.stderr.strip()) return 0 print(out.stdout, end="") return 0 def logs(lines: int) -> int: stdout = log_dir() / "codex-skill-eval.launchd.stdout.log" stderr = log_dir() / "codex-skill-eval.launchd.stderr.log" for path in [stdout, stderr]: print(f"==> {path}") if not path.exists(): print("(missing)") continue text = path.read_text(encoding="utf-8", errors="replace").splitlines() for line in text[-lines:]: print(line) return 0 def main() -> int: parser = argparse.ArgumentParser( description="Manage launchd schedule for Flow Codex skill-eval cron." ) sub = parser.add_subparsers(dest="cmd", required=True) p_install = sub.add_parser("install") p_install.add_argument("--minutes", type=int, default=30) p_install.add_argument("--limit", type=int, default=400) p_install.add_argument("--max-targets", type=int, default=12) p_install.add_argument("--within-hours", type=int, default=168) p_install.add_argument("--dry-run", action="store_true") sub.add_parser("uninstall") sub.add_parser("status") p_logs = sub.add_parser("logs") p_logs.add_argument("--lines", type=int, default=120) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[1] if args.cmd == "install": return install( repo_root, args.minutes, args.limit, args.max_targets, args.within_hours, args.dry_run, ) if args.cmd == "uninstall": return uninstall() if args.cmd == "status": return status() if args.cmd == "logs": return logs(args.lines) return 1 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/codex_fork.py ================================================ #!/usr/bin/env python3 from __future__ import annotations import argparse import os import re import shlex import subprocess import sys from pathlib import Path def env_path(name: str, default: Path) -> Path: value = os.environ.get(name) if not value: return default return Path(value).expanduser() HOME = Path.home() UPSTREAM_CHECKOUT = env_path( "FLOW_CODEX_UPSTREAM_CHECKOUT", HOME / "repos" / "openai" / "codex", ) FORK_HOME = env_path( "FLOW_CODEX_FORK_HOME", HOME / "repos" / "nikivdev" / "codex", ) WORKTREE_ROOT = env_path( "FLOW_CODEX_WORKTREE_ROOT", HOME / ".worktrees" / "codex", ) WORKFLOW_DOC = env_path( "FLOW_CODEX_WORKFLOW_DOC", HOME / "docs" / "codex" / "codex-fork-home-branch-workflow.md", ) STATE_DIR = env_path( "FLOW_CODEX_FORK_STATE_DIR", HOME / ".flow" / "codex-fork", ) LAST_WORKTREE_FILE = STATE_DIR / "last-worktree.txt" DEFAULT_BASE_BRANCH = os.environ.get("FLOW_CODEX_FORK_BASE_BRANCH", "nikiv") DEFAULT_BRANCH_PREFIX = os.environ.get("FLOW_CODEX_FORK_BRANCH_PREFIX", "codex") DEFAULT_REVIEW_PREFIX = os.environ.get("FLOW_CODEX_FORK_REVIEW_PREFIX", "review/nikiv") DEFAULT_PRIVATE_REMOTE = os.environ.get("FLOW_CODEX_FORK_PRIVATE_REMOTE", "private") DEFAULT_UPSTREAM_REMOTE = os.environ.get("FLOW_CODEX_FORK_UPSTREAM_REMOTE", "upstream") DEFAULT_UPSTREAM_BRANCH = os.environ.get("FLOW_CODEX_FORK_UPSTREAM_BRANCH", "main") def fail(message: str, code: int = 1) -> int: print(f"Error: {message}", file=sys.stderr) return code def run( cmd: list[str], *, cwd: Path | None = None, capture: bool = False, check: bool = True, ) -> subprocess.CompletedProcess[str]: result = subprocess.run( cmd, cwd=str(cwd) if cwd is not None else None, text=True, capture_output=capture, check=False, ) if check and result.returncode != 0: if capture and result.stderr: print(result.stderr.rstrip(), file=sys.stderr) raise SystemExit(result.returncode) return result def capture(cmd: list[str], *, cwd: Path | None = None, check: bool = True) -> str: result = run(cmd, cwd=cwd, capture=True, check=check) return result.stdout.strip() def ensure_repo(path: Path, label: str) -> None: if not path.exists(): raise SystemExit(fail(f"{label} does not exist: {path}")) probe = run( ["git", "rev-parse", "--is-inside-work-tree"], cwd=path, capture=True, check=False, ) if probe.returncode != 0 or probe.stdout.strip() != "true": raise SystemExit(fail(f"{label} is not a git checkout: {path}")) def ensure_state_dir() -> None: STATE_DIR.mkdir(parents=True, exist_ok=True) def read_last_worktree() -> Path | None: if not LAST_WORKTREE_FILE.exists(): return None value = LAST_WORKTREE_FILE.read_text().strip() if not value: return None return Path(value).expanduser() def write_last_worktree(path: Path) -> None: ensure_state_dir() LAST_WORKTREE_FILE.write_text(f"{path}\n") def slugify(text: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") slug = re.sub(r"-{2,}", "-", slug) if not slug: raise SystemExit(fail(f"could not derive a branch slug from query: {text!r}")) return slug def branch_to_worktree_name(branch: str) -> str: return branch.replace("/", "-") def git_ref_exists(repo: Path, ref: str) -> bool: result = run( ["git", "show-ref", "--verify", "--quiet", ref], cwd=repo, check=False, ) return result.returncode == 0 def git_branch_exists(repo: Path, branch: str) -> bool: return git_ref_exists(repo, f"refs/heads/{branch}") def git_current_branch(repo: Path) -> str: branch = capture(["git", "branch", "--show-current"], cwd=repo) if not branch: raise SystemExit(fail(f"could not resolve current branch in {repo}")) return branch def git_rev(repo: Path, ref: str) -> str | None: result = run(["git", "rev-parse", "--verify", ref], cwd=repo, capture=True, check=False) if result.returncode != 0: return None return result.stdout.strip() def worktree_entries(repo: Path) -> list[dict[str, str]]: output = capture(["git", "worktree", "list", "--porcelain"], cwd=repo) entries: list[dict[str, str]] = [] current: dict[str, str] = {} for line in output.splitlines(): if not line: if current: entries.append(current) current = {} continue key, _, value = line.partition(" ") if key == "worktree": current["path"] = value elif key == "branch": current["branch"] = value.removeprefix("refs/heads/") elif key == "HEAD": current["head"] = value elif key == "detached": current["detached"] = "true" if current: entries.append(current) return entries def worktree_for_branch(repo: Path, branch: str) -> Path | None: for entry in worktree_entries(repo): if entry.get("branch") == branch: return Path(entry["path"]) return None def branch_from_target(target: str) -> str: if target.startswith("codex/") or target.startswith("review/"): return target return f"{DEFAULT_BRANCH_PREFIX}/{slugify(target)}" def default_worktree_path(branch: str) -> Path: return WORKTREE_ROOT / branch_to_worktree_name(branch) def ensure_task_worktree(branch: str, path: Path, base: str) -> tuple[Path, bool]: existing = worktree_for_branch(FORK_HOME, branch) if existing is not None: return existing, True if path.exists() and not (path / ".git").exists(): if any(path.iterdir()): raise SystemExit( fail(f"requested worktree path already exists and is not empty: {path}") ) path.parent.mkdir(parents=True, exist_ok=True) if git_branch_exists(FORK_HOME, branch): run(["git", "worktree", "add", str(path), branch], cwd=FORK_HOME) else: run(["git", "worktree", "add", "-b", branch, str(path), base], cwd=FORK_HOME) return path, False def build_prompt(query: str, branch: str, worktree: Path, base: str) -> str: return "\n".join( [ f"Read {WORKFLOW_DOC} and make plan first.", "", f"Task: {query}", f"Branch: {branch}", f"Worktree: {worktree}", f"Base branch: {base}", f"Fork home checkout: {FORK_HOME}", f"Upstream reference checkout: {UPSTREAM_CHECKOUT}", "Keep the work scoped to this branch/worktree and do not touch unrelated fork worktrees.", ] ) def launch_codex_new(worktree: Path, prompt: str) -> int: cmd = [ "codex", "--cd", str(worktree), "--yolo", "--sandbox", "danger-full-access", prompt, ] return subprocess.run(cmd, check=False).returncode def launch_codex_resume_last(worktree: Path, prompt: str | None = None) -> int: cmd = [ "codex", "--cd", str(worktree), "resume", "--last", "--dangerously-bypass-approvals-and-sandbox", ] if prompt: cmd.append(prompt) return subprocess.run(cmd, check=False).returncode def print_next_commands(worktree: Path, branch: str, prompt: str) -> None: print(f"branch: {branch}") print(f"worktree: {worktree}") print() print("next:") print(f" cd {shlex.quote(str(worktree))}") print( " " + shlex.join( [ "codex", "--cd", str(worktree), "--yolo", "--sandbox", "danger-full-access", prompt, ] ) ) def cmd_status(_args: argparse.Namespace) -> int: ensure_repo(FORK_HOME, "codex fork home checkout") print("# Codex fork workflow") print(f"upstream checkout: {UPSTREAM_CHECKOUT}") print(f"fork home: {FORK_HOME}") print(f"worktree root: {WORKTREE_ROOT}") print(f"workflow doc: {WORKFLOW_DOC}") print() nikiv_sha = git_rev(FORK_HOME, DEFAULT_BASE_BRANCH) upstream_sha = git_rev(FORK_HOME, f"{DEFAULT_UPSTREAM_REMOTE}/{DEFAULT_UPSTREAM_BRANCH}") private_sha = git_rev(FORK_HOME, f"{DEFAULT_PRIVATE_REMOTE}/{DEFAULT_BASE_BRANCH}") print("# Branch heads") print(f"{DEFAULT_BASE_BRANCH}: {nikiv_sha or 'missing'}") print(f"{DEFAULT_UPSTREAM_REMOTE}/{DEFAULT_UPSTREAM_BRANCH}: {upstream_sha or 'missing'}") print(f"{DEFAULT_PRIVATE_REMOTE}/{DEFAULT_BASE_BRANCH}: {private_sha or 'missing'}") print() print("# Remotes") print(capture(["git", "remote", "-v"], cwd=FORK_HOME)) print() print("# Worktrees") for entry in worktree_entries(FORK_HOME): branch = entry.get("branch", "(detached)") print(f"{branch:32} {entry['path']}") print() last = read_last_worktree() print("# Last worktree") if last is None: print("none recorded") return 0 print(last) if last.exists(): print() print("# Last worktree status") status = capture(["git", "status", "-sb"], cwd=last, check=False) if status: print(status) return 0 def cmd_sync(args: argparse.Namespace) -> int: ensure_repo(FORK_HOME, "codex fork home checkout") status = capture(["git", "status", "--porcelain"], cwd=FORK_HOME) if status: return fail( f"{FORK_HOME} is dirty; clean or stash it before syncing {DEFAULT_BASE_BRANCH}" ) run( ["git", "fetch", DEFAULT_UPSTREAM_REMOTE, DEFAULT_UPSTREAM_BRANCH], cwd=FORK_HOME, ) if git_ref_exists(FORK_HOME, f"refs/remotes/{DEFAULT_PRIVATE_REMOTE}/{DEFAULT_BASE_BRANCH}"): run(["git", "fetch", DEFAULT_PRIVATE_REMOTE, DEFAULT_BASE_BRANCH], cwd=FORK_HOME) run(["git", "switch", DEFAULT_BASE_BRANCH], cwd=FORK_HOME) run( ["git", "merge", "--ff-only", f"{DEFAULT_UPSTREAM_REMOTE}/{DEFAULT_UPSTREAM_BRANCH}"], cwd=FORK_HOME, ) if args.push: run(["git", "push", DEFAULT_PRIVATE_REMOTE, DEFAULT_BASE_BRANCH], cwd=FORK_HOME) print(f"{DEFAULT_BASE_BRANCH} now matches {DEFAULT_UPSTREAM_REMOTE}/{DEFAULT_UPSTREAM_BRANCH}") if args.push: print(f"pushed {DEFAULT_BASE_BRANCH} to {DEFAULT_PRIVATE_REMOTE}") return 0 def cmd_task(args: argparse.Namespace) -> int: ensure_repo(FORK_HOME, "codex fork home checkout") branch = args.branch or branch_from_target(args.query) worktree = Path(args.path).expanduser() if args.path else default_worktree_path(branch) worktree, reused = ensure_task_worktree(branch, worktree, args.base) write_last_worktree(worktree) prompt = build_prompt(args.query, branch, worktree, args.base) print(f"branch: {branch}") print(f"worktree: {worktree}") print(f"mode: {'resume-or-new' if not args.new else 'new'}") print() if args.no_launch: print_next_commands(worktree, branch, prompt) return 0 if reused and not args.new: resume_code = launch_codex_resume_last(worktree) if resume_code == 0: return 0 print("No prior Codex session found for that worktree; starting a new one.", file=sys.stderr) return launch_codex_new(worktree, prompt) def resolve_target_worktree(target: str | None) -> Path: if target: candidate = Path(target).expanduser() if candidate.exists(): return candidate branch = branch_from_target(target) existing = worktree_for_branch(FORK_HOME, branch) if existing is not None: return existing raise SystemExit(fail(f"could not resolve worktree for target: {target}")) last = read_last_worktree() if last is None: raise SystemExit( fail("no last Codex fork worktree recorded yet; start one with `f codex-fork-task`") ) return last def cmd_last(args: argparse.Namespace) -> int: ensure_repo(FORK_HOME, "codex fork home checkout") worktree = resolve_target_worktree(args.target) if not worktree.exists(): return fail(f"recorded worktree does not exist: {worktree}") write_last_worktree(worktree) return launch_codex_resume_last(worktree) def review_branch_for(source_branch: str) -> str: if source_branch.startswith("review/"): return source_branch if source_branch.startswith("codex/"): suffix = source_branch.removeprefix("codex/").replace("/", "-") return f"{DEFAULT_REVIEW_PREFIX}-{suffix}" return f"{DEFAULT_REVIEW_PREFIX}-{source_branch.replace('/', '-')}" def cmd_promote(args: argparse.Namespace) -> int: ensure_repo(FORK_HOME, "codex fork home checkout") target = resolve_target_worktree(args.target) ensure_repo(target, "codex fork worktree") source_branch = git_current_branch(target) review_branch = args.review_branch or review_branch_for(source_branch) source_commit = capture(["git", "rev-parse", "HEAD"], cwd=target) run(["git", "branch", "-f", review_branch, source_commit], cwd=FORK_HOME) print(f"source branch: {source_branch}") print(f"review branch: {review_branch}") print(f"commit: {source_commit}") if args.push: run(["git", "push", "-u", DEFAULT_PRIVATE_REMOTE, review_branch], cwd=FORK_HOME) print(f"pushed to {DEFAULT_PRIVATE_REMOTE}/{review_branch}") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Automate the personal Codex fork home-branch/worktree workflow." ) subparsers = parser.add_subparsers(dest="command", required=True) status = subparsers.add_parser("status", help="Show fork checkout/worktree state.") status.set_defaults(func=cmd_status) sync = subparsers.add_parser( "sync", help="Fast-forward nikiv in the personal fork checkout to upstream/main.", ) sync.add_argument( "--push", action="store_true", help="Also push nikiv to the private remote after the fast-forward.", ) sync.set_defaults(func=cmd_sync) task = subparsers.add_parser( "task", help="Create or reuse a scoped worktree for a Codex fork task and launch Codex there.", ) task.add_argument("query", help="Natural-language task; used for branch slug + initial prompt.") task.add_argument( "--branch", help="Explicit branch name to use instead of deriving codex/<slug> from the query.", ) task.add_argument( "--base", default=DEFAULT_BASE_BRANCH, help=f"Base branch/ref for new worktrees (default: {DEFAULT_BASE_BRANCH}).", ) task.add_argument( "--path", help="Explicit worktree path to use instead of ~/.worktrees/codex/<branch>.", ) task.add_argument( "--new", action="store_true", help="Always start a fresh Codex session instead of trying resume --last first.", ) task.add_argument( "--no-launch", action="store_true", help="Only create/reuse the worktree and print the next command instead of launching Codex.", ) task.set_defaults(func=cmd_task) last = subparsers.add_parser( "last", help="Resume the last Codex session in the last used fork worktree.", ) last.add_argument( "target", nargs="?", help="Optional branch name or worktree path. Defaults to the last used fork worktree.", ) last.set_defaults(func=cmd_last) promote = subparsers.add_parser( "promote", help="Create or update a review/nikiv-* branch from a codex/* worktree branch.", ) promote.add_argument( "target", nargs="?", help="Optional branch name or worktree path. Defaults to the last used fork worktree.", ) promote.add_argument( "--review-branch", help="Explicit review branch name instead of deriving review/nikiv-<slug>.", ) promote.add_argument( "--push", action="store_true", help="Also push the promoted review branch to the private remote.", ) promote.set_defaults(func=cmd_promote) return parser def main() -> int: parser = build_parser() args = parser.parse_args() return args.func(args) if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/deploy.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" cd "${ROOT_DIR}" PROFILE="${FLOW_PROFILE:-debug}" TARGET_DIR="debug" BUILD_ARGS=() [[ "${PROFILE}" == "release" ]] && TARGET_DIR="release" && BUILD_ARGS+=("--release") append_rustflag() { local flag="$1" if [[ -n "${RUSTFLAGS:-}" ]]; then RUSTFLAGS+=" ${flag}" else RUSTFLAGS="${flag}" fi } if [[ "${PROFILE}" == "release" ]]; then export CARGO_INCREMENTAL=0 if [[ "$(uname -s)" == "Darwin" ]]; then append_rustflag "-C target-cpu=${FLOW_DEPLOY_TARGET_CPU:-native}" append_rustflag "-C link-arg=-Wl,-dead_strip" append_rustflag "-C link-arg=-Wl,-dead_strip_dylibs" fi if [[ -n "${FLOW_DEPLOY_RUSTFLAGS:-}" ]]; then append_rustflag "${FLOW_DEPLOY_RUSTFLAGS}" fi export RUSTFLAGS fi # Build cargo build "${BUILD_ARGS[@]}" --quiet SOURCE_F="${ROOT_DIR}/target/${TARGET_DIR}/f" SOURCE_LIN="${ROOT_DIR}/target/${TARGET_DIR}/lin" PRIMARY_DIR="${HOME}/bin" ALT_DIR="${HOME}/.local/bin" PRIMARY_F="$(command -v f 2>/dev/null || true)" PRIMARY_INSTALLED=false ad_hoc_sign_if_available() { local bin_path="$1" [[ -f "$bin_path" ]] || return 0 if command -v codesign >/dev/null 2>&1; then # Avoid macOS "load code signature error" on copied local binaries. codesign --force --sign - --timestamp=none "$bin_path" >/dev/null 2>&1 || true fi } if [[ -n "${PRIMARY_F}" ]]; then PRIMARY_DIR="$(dirname -- "${PRIMARY_F}")" fi install_to_dir() { local dir="$1" [[ -d "${dir}" ]] || return 0 [[ -w "${dir}" ]] || return 0 # Copy binaries (more reliable than symlinks) if [[ -e "${dir}/f" && "${SOURCE_F}" -ef "${dir}/f" ]]; then : else cp -f "${SOURCE_F}" "${dir}/f" 2>/dev/null || return 1 fi ad_hoc_sign_if_available "${dir}/f" if [[ -e "${dir}/flow" && "${SOURCE_F}" -ef "${dir}/flow" ]]; then : else cp -f "${SOURCE_F}" "${dir}/flow" 2>/dev/null || true fi ad_hoc_sign_if_available "${dir}/flow" if [[ -e "${dir}/lin" && "${SOURCE_LIN}" -ef "${dir}/lin" ]]; then : else cp -f "${SOURCE_LIN}" "${dir}/lin" 2>/dev/null || true fi ad_hoc_sign_if_available "${dir}/lin" return 0 } mkdir -p "${PRIMARY_DIR}" if install_to_dir "${PRIMARY_DIR}"; then PRIMARY_INSTALLED=true fi # If ~/.local/bin exists, link to the primary install for consistency. if [[ -d "${ALT_DIR}" ]]; then ln -sf "${PRIMARY_DIR}/f" "${ALT_DIR}/f" ln -sf "${PRIMARY_DIR}/f" "${ALT_DIR}/flow" ln -sf "${PRIMARY_DIR}/lin" "${ALT_DIR}/lin" fi # Verify if command -v f &>/dev/null; then echo "flow ${PROFILE} build installed" else echo "Installed to ~/bin - add to PATH: export PATH=\"\$HOME/bin:\$PATH\"" fi ================================================ FILE: scripts/deps_check.py ================================================ #!/usr/bin/env python3 from __future__ import annotations import argparse import json import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path from typing import Any def run_json(cmd: list[str], *, cwd: Path) -> Any: result = subprocess.run( cmd, cwd=cwd, text=True, capture_output=True, check=False, ) if result.returncode != 0: raise RuntimeError( f"command failed: {' '.join(cmd)}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) return json.loads(result.stdout) def fetch_latest(crate: str, cache: dict[str, str]) -> str: if crate in cache: return cache[crate] url = f"https://crates.io/api/v1/crates/{crate}" request = urllib.request.Request( url, headers={ "Accept": "application/json", "User-Agent": "flow-deps-check/1.0", }, ) last_error: Exception | None = None for attempt in range(3): try: with urllib.request.urlopen(request, timeout=30) as response: payload = json.load(response) break except (TimeoutError, urllib.error.URLError) as error: last_error = error if attempt == 2: raise RuntimeError(f"failed to fetch latest version for {crate}: {error}") from error time.sleep(1.0 + attempt) else: raise RuntimeError(f"failed to fetch latest version for {crate}: {last_error}") latest = payload["crate"].get("max_stable_version") or payload["crate"]["newest_version"] cache[crate] = latest return latest def load_vendor_rows(repo_root: Path) -> list[dict[str, Any]]: rows = run_json(["scripts/vendor/check-upstream.sh", "--json"], cwd=repo_root) rows.sort(key=lambda row: row["crate"]) return rows def load_direct_rows(repo_root: Path) -> list[dict[str, Any]]: metadata = run_json(["cargo", "metadata", "--format-version", "1", "--locked"], cwd=repo_root) packages_by_id = {pkg["id"]: pkg for pkg in metadata["packages"]} nodes_by_id = {node["id"]: node for node in metadata["resolve"]["nodes"]} workspace_member_ids = set(metadata.get("workspace_members", [])) latest_cache: dict[str, str] = {} rows: list[dict[str, Any]] = [] seen_rows: set[tuple[str, str, tuple[str, ...], str]] = set() for member_id in sorted(workspace_member_ids): workspace_pkg = packages_by_id[member_id] workspace_name = workspace_pkg["name"] workspace_node = nodes_by_id[member_id] for dep in workspace_node.get("deps", []): pkg_id = dep["pkg"] pkg = packages_by_id[pkg_id] if pkg.get("source") is None: continue kinds = tuple( sorted( { dep_kind.get("kind") or "normal" for dep_kind in dep.get("dep_kinds", []) } ) ) or ("normal",) row_key = (workspace_name, pkg["name"], kinds, pkg["version"]) if row_key in seen_rows: continue seen_rows.add(row_key) current = pkg["version"] latest = fetch_latest(pkg["name"], latest_cache) rows.append( { "workspace": workspace_name, "crate": pkg["name"], "current": current, "latest": latest, "kinds": list(kinds), "status": "up-to-date" if current == latest else "update-available", } ) rows.sort(key=lambda row: (row["workspace"], row["crate"])) return rows def print_rows(title: str, rows: list[dict[str, Any]], *, include_kinds: bool) -> None: print(title) if not rows: print(" none") return for row in rows: suffix = "" if include_kinds: suffix = f" [{row['workspace']}] ({','.join(row['kinds'])})" print( f" {row['crate']}{suffix}: current={row['current']} latest={row['latest']} status={row['status']}" ) def main() -> int: parser = argparse.ArgumentParser( description="Check Flow vendored and direct Cargo dependencies against the latest upstream releases." ) parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON") args = parser.parse_args() repo_root = Path(__file__).resolve().parent.parent vendor_rows = load_vendor_rows(repo_root) direct_rows = load_direct_rows(repo_root) stale_vendor = [row for row in vendor_rows if row["status"] != "up-to-date"] stale_direct = [row for row in direct_rows if row["status"] != "up-to-date"] payload = { "vendor": vendor_rows, "direct": direct_rows, "ok": not stale_vendor and not stale_direct, } if args.json: print(json.dumps(payload, indent=2)) else: print_rows("Vendored deps", vendor_rows, include_kinds=False) print_rows("Direct Cargo deps", direct_rows, include_kinds=True) print() if payload["ok"]: print("deps-check: ok") else: print( f"deps-check: failed ({len(stale_vendor)} vendored stale, {len(stale_direct)} direct stale)" ) return 0 if payload["ok"] else 1 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/generate_help_full_json.py ================================================ #!/usr/bin/env python3 from __future__ import annotations import os import pathlib import subprocess import sys def main() -> int: root = pathlib.Path(__file__).resolve().parent.parent output = root / "src" / "help_full.json" env = os.environ.copy() env["FLOW_REGENERATE_HELP_FULL"] = "1" cmd = ["cargo", "run", "--quiet", "--bin", "f", "--", "--help-full"] result = subprocess.run(cmd, cwd=root, env=env, capture_output=True, text=True) if result.returncode != 0: sys.stderr.write(result.stderr) return result.returncode output.write_text(result.stdout, encoding="utf-8") print(output) return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/install-linux-hub.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Installs the flow hub daemon on a Linux machine that can reach the supplied # binary/config URLs. Intended to be run via: # curl -fsSL https://raw.githubusercontent.com/nikiv/flow/main/scripts/install-linux-hub.sh | \ # sudo FLOW_BINARY_URL=https://example.com/f-linux FLOW_CONFIG_URL=https://example.com/config.toml bash if [[ "${EUID}" -ne 0 ]]; then echo "This installer must run as root (use sudo)." >&2 exit 1 fi FLOW_BINARY_URL="${FLOW_BINARY_URL:-}" FLOW_CONFIG_URL="${FLOW_CONFIG_URL:-}" FLOW_ROOT="${FLOW_ROOT:-/opt/flow}" FLOW_USER="${FLOW_USER:-flow}" FLOW_PORT="${FLOW_PORT:-9050}" FLOW_SERVICE_NAME="${FLOW_SERVICE_NAME:-flowd}" if [[ -z "${FLOW_BINARY_URL}" ]]; then echo "FLOW_BINARY_URL must be set to a downloadable flow binary." >&2 exit 1 fi if [[ -z "${FLOW_CONFIG_URL}" ]]; then echo "FLOW_CONFIG_URL must be set to a downloadable flow.toml." >&2 exit 1 fi command -v curl >/dev/null 2>&1 || { echo "curl is required to download assets. Install it and retry." >&2 exit 1 } if [[ ! -d /run/systemd/system ]]; then echo "systemd is required to install the hub as a service." >&2 exit 1 fi if ! id -u "${FLOW_USER}" >/dev/null 2>&1; then echo "Creating system user ${FLOW_USER}" useradd --system --create-home --shell /usr/sbin/nologin "${FLOW_USER}" fi BIN_DIR="${FLOW_ROOT}/bin" CONFIG_DIR="${FLOW_ROOT}/config" mkdir -p "${BIN_DIR}" "${CONFIG_DIR}" chown -R "${FLOW_USER}:${FLOW_USER}" "${FLOW_ROOT}" BIN_PATH="${BIN_DIR}/f" CONFIG_PATH="${CONFIG_DIR}/flow.toml" echo "Downloading flow binary from ${FLOW_BINARY_URL}" curl -fsSL "${FLOW_BINARY_URL}" -o "${BIN_PATH}" chmod +x "${BIN_PATH}" chown "${FLOW_USER}:${FLOW_USER}" "${BIN_PATH}" echo "Downloading flow config from ${FLOW_CONFIG_URL}" curl -fsSL "${FLOW_CONFIG_URL}" -o "${CONFIG_PATH}" chown "${FLOW_USER}:${FLOW_USER}" "${CONFIG_PATH}" UNIT_FILE="/etc/systemd/system/${FLOW_SERVICE_NAME}.service" cat <<EOF >"${UNIT_FILE}" [Unit] Description=Flow hub daemon After=network.target [Service] Type=simple Environment=FLOW_CONFIG=${CONFIG_PATH} ExecStart=${BIN_PATH} daemon --host 0.0.0.0 --port ${FLOW_PORT} Restart=always RestartSec=5 User=${FLOW_USER} WorkingDirectory=${FLOW_ROOT} [Install] WantedBy=multi-user.target EOF echo "Enabling ${FLOW_SERVICE_NAME} systemd unit" systemctl daemon-reload systemctl enable --now "${FLOW_SERVICE_NAME}" echo "Flow hub installed." echo "Check status with: sudo systemctl status ${FLOW_SERVICE_NAME}" echo "Verify health: curl http://<tailscale-ip>:${FLOW_PORT}/health" ================================================ FILE: scripts/install-macos-dev.sh ================================================ #!/usr/bin/env bash set -euo pipefail # macOS-only dev installer for Flow + local Jazz2. # Clones Flow + Jazz2, builds, and # symlinks binaries into ~/.local/bin. fail() { echo "flow macos dev install: $*" >&2 exit 1 } info() { echo "flow macos dev install: $*" } if [[ "$(uname -s)" != "Darwin" ]]; then fail "this script is macOS-only" fi BASE_DIR="${FLOW_DEV_ROOT:-$HOME/code/org/1f}" FLOW_REPO_URL="${FLOW_REPO_URL:-https://github.com/nikivdev/flow}" JAZZ_REPO_URL="${FLOW_JAZZ_URL:-https://github.com/garden-co/jazz2}" FLOW_DIR="${FLOW_DEV_FLOW_DIR:-$BASE_DIR/flow}" JAZZ_DIR="${FLOW_DEV_JAZZ_DIR:-$BASE_DIR/jazz2}" BIN_DIR="${FLOW_BIN_DIR:-$HOME/.local/bin}" USE_SSH="${FLOW_GIT_SSH:-}" GITHUB_TOKEN="${FLOW_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" JAZZ_OPTIONAL="${FLOW_JAZZ_OPTIONAL:-1}" JAZZ_AVAILABLE=1 DIST_DIR="${FLOW_DIST_DIR:-${SCRIPT_DIR}/../dist}" FORCE_HTTPS=0 ensure_brew() { if command -v brew >/dev/null 2>&1; then return 0 fi info "installing Homebrew..." /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" if [[ -f "/opt/homebrew/bin/brew" ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [[ -f "/usr/local/bin/brew" ]]; then eval "$(/usr/local/bin/brew shellenv)" fi } ensure_fnm_and_node() { if ! command -v fnm >/dev/null 2>&1; then info "installing fnm..." brew install fnm fi # Ensure fnm is active in this shell eval "$(fnm env)" if ! command -v node >/dev/null 2>&1; then info "installing Node.js (LTS) via fnm..." install_out="$(fnm install --lts 2>&1)" || fail "fnm install --lts failed" echo "$install_out" installed_version="$(printf "%s\n" "$install_out" | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | tail -n1 || true)" if [[ -n "${installed_version}" ]]; then fnm default "${installed_version}" || true fi fi } ensure_fzf() { if command -v fzf >/dev/null 2>&1; then return 0 fi info "installing fzf..." brew install fzf } ensure_rust() { if command -v cargo >/dev/null 2>&1; then return 0 fi info "installing Rust..." curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y # shellcheck disable=SC1090 source "$HOME/.cargo/env" } check_github_ssh() { if [[ "${FLOW_FORCE_HTTPS:-}" = "1" ]]; then FORCE_HTTPS=1 return 0 fi if [[ "${FLOW_SSH_MODE:-}" = "https" ]]; then FORCE_HTTPS=1 return 0 fi if ! command -v ssh >/dev/null 2>&1; then return 0 fi local out out="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -T git@github.com 2>&1 || true)" if echo "${out}" | grep -qi "successfully authenticated"; then info "GitHub SSH auth OK." return 0 fi if echo "${out}" | grep -qi "Permission denied"; then FORCE_HTTPS=1 info "GitHub SSH auth failed; configuring Flow to prefer HTTPS." fi } clone_or_update() { mkdir -p "${BASE_DIR}" local resolved_jazz_url local resolved_flow_url resolved_jazz_url="$(resolve_repo_url "${JAZZ_REPO_URL}")" resolved_flow_url="$(resolve_repo_url "${FLOW_REPO_URL}")" if [[ -d "${JAZZ_DIR}/.git" ]]; then info "updating Jazz2..." (cd "${JAZZ_DIR}" && GIT_TERMINAL_PROMPT=0 git pull --rebase) || true else info "cloning Jazz2 to ${JAZZ_DIR}..." if ! GIT_TERMINAL_PROMPT=0 git clone "${resolved_jazz_url}" "${JAZZ_DIR}"; then if [[ "${JAZZ_OPTIONAL}" != "0" ]]; then info "Jazz2 clone failed; continuing without local Jazz2 (release fallback)." info "To build with local Jazz2, set FLOW_GIT_SSH=1 or FLOW_GITHUB_TOKEN=... and rerun." if [[ -x "${SCRIPT_DIR}/setup-github-ssh.sh" ]]; then info "running SSH setup helper so you can add the key to GitHub if needed..." "${SCRIPT_DIR}/setup-github-ssh.sh" || true fi JAZZ_AVAILABLE=0 else fail_clone "${JAZZ_REPO_URL}" fi fi fi if [[ -d "${FLOW_DIR}/.git" ]]; then info "updating Flow..." (cd "${FLOW_DIR}" && GIT_TERMINAL_PROMPT=0 git pull --rebase) || true else info "cloning Flow to ${FLOW_DIR}..." GIT_TERMINAL_PROMPT=0 git clone "${resolved_flow_url}" "${FLOW_DIR}" || fail_clone "${FLOW_REPO_URL}" fi } resolve_repo_url() { local url="$1" if [[ -n "${USE_SSH}" ]]; then if [[ "${url}" =~ ^https://github.com/([^/]+)/([^/]+)(\.git)?$ ]]; then echo "git@github.com:${BASH_REMATCH[1]}/${BASH_REMATCH[2]}.git" return fi fi if [[ -n "${GITHUB_TOKEN}" ]]; then if [[ "${url}" =~ ^https://github.com/(.+)$ ]]; then echo "https://x-access-token:${GITHUB_TOKEN}@github.com/${BASH_REMATCH[1]}" return fi fi echo "${url}" } fail_clone() { local url="$1" info "" info "clone failed for ${url}" if [[ -x "${SCRIPT_DIR}/setup-github-ssh.sh" ]]; then info "attempting to provision GitHub SSH key..." "${SCRIPT_DIR}/setup-github-ssh.sh" || true fi info "If this repo is private, set one of:" info " FLOW_GITHUB_TOKEN=... (or GITHUB_TOKEN=...)" info " FLOW_GIT_SSH=1 (uses git@github.com:... and your SSH key)" info " FLOW_JAZZ_OPTIONAL=0 to require Jazz2 and fail fast" fail "unable to clone ${url}" } write_cargo_patch() { return 0 } build_and_link() { cleanup_stale_links if [[ "${JAZZ_AVAILABLE}" = "0" ]]; then install_release_fallback return 0 fi info "building Flow..." (cd "${FLOW_DIR}" && cargo build --release) mkdir -p "${BIN_DIR}" ln -sf "${FLOW_DIR}/target/release/f" "${BIN_DIR}/f" ln -sf "${FLOW_DIR}/target/release/f" "${BIN_DIR}/flow" if [[ -f "${FLOW_DIR}/target/release/lin" ]]; then ln -sf "${FLOW_DIR}/target/release/lin" "${BIN_DIR}/lin" fi } install_release_fallback() { local root_installer="${SCRIPT_DIR}/../install.sh" if install_local_dist; then return 0 fi info "installing Flow from release (no Jazz access)..." if [[ -x "${root_installer}" ]]; then if ! FLOW_INSTALL_PATH="${BIN_DIR}/f" "${root_installer}"; then info "release install failed." info "If you don't have access to the private Jazz repo, you need a public Flow release." fail "release install unavailable" fi ln -sf "${BIN_DIR}/f" "${BIN_DIR}/flow" return 0 fi if [[ -x "${SCRIPT_DIR}/install.sh" ]]; then if ! FLOW_SKIP_DEPS=1 FLOW_BIN_DIR="${BIN_DIR}" FLOW_RELEASE_ONLY=1 "${SCRIPT_DIR}/install.sh"; then info "release install failed." info "If you don't have access to the private Jazz repo, you need a public Flow release." fail "release install unavailable" fi return 0 fi fail "no installer found for release fallback" } install_local_dist() { local arch local pattern local tarball local tmpdir local binary if [[ ! -d "${DIST_DIR}" ]]; then return 1 fi arch="$(uname -m)" case "${arch}" in arm64) pattern="*_darwin_arm64.tar.gz" ;; x86_64) pattern="*_darwin_x64.tar.gz" ;; *) return 1 ;; esac tarball="$(ls -t "${DIST_DIR}"/${pattern} 2>/dev/null | head -n1 || true)" if [[ -z "${tarball}" ]]; then # fallback for amd64 naming if [[ "${arch}" = "x86_64" ]]; then tarball="$(ls -t "${DIST_DIR}"/*_darwin_amd64.tar.gz 2>/dev/null | head -n1 || true)" fi fi if [[ -z "${tarball}" ]]; then return 1 fi info "installing Flow from local dist: ${tarball}" tmpdir="$(mktemp -d)" tar -xzf "${tarball}" -C "${tmpdir}" if [[ -f "${tmpdir}/f" ]]; then binary="${tmpdir}/f" else binary="$(find "${tmpdir}" -type f \( -name "f" -o -name "flow" \) 2>/dev/null | head -n1 || true)" fi if [[ -z "${binary}" ]]; then rm -rf "${tmpdir}" return 1 fi cleanup_stale_links mkdir -p "${BIN_DIR}" cp "${binary}" "${BIN_DIR}/f" chmod +x "${BIN_DIR}/f" ln -sf "${BIN_DIR}/f" "${BIN_DIR}/flow" if [[ -f "${tmpdir}/lin" ]]; then cp "${tmpdir}/lin" "${BIN_DIR}/lin" chmod +x "${BIN_DIR}/lin" fi rm -rf "${tmpdir}" return 0 } cleanup_stale_links() { local home_bin="$HOME/bin" rm -f "${BIN_DIR}/f" "${BIN_DIR}/flow" if [[ -d "${home_bin}" ]]; then rm -f "${home_bin}/f" "${home_bin}/flow" fi } ensure_shell_setup() { local zshrc="$HOME/.zshrc" local bashrc="$HOME/.bashrc" local bash_profile="$HOME/.bash_profile" local path_line="export PATH=\"${BIN_DIR}:\$PATH\"" local fnm_line='eval "$(fnm env --use-on-cd)"' local https_line='export FLOW_FORCE_HTTPS=1' if [[ -f "${zshrc}" ]]; then grep -qF "${path_line}" "${zshrc}" || echo "${path_line}" >> "${zshrc}" grep -qF "${fnm_line}" "${zshrc}" || echo "${fnm_line}" >> "${zshrc}" if [[ "${FORCE_HTTPS}" = "1" ]]; then grep -qF "${https_line}" "${zshrc}" || echo "${https_line}" >> "${zshrc}" fi elif [[ -f "${bashrc}" ]]; then grep -qF "${path_line}" "${bashrc}" || echo "${path_line}" >> "${bashrc}" grep -qF "${fnm_line}" "${bashrc}" || echo "${fnm_line}" >> "${bashrc}" if [[ "${FORCE_HTTPS}" = "1" ]]; then grep -qF "${https_line}" "${bashrc}" || echo "${https_line}" >> "${bashrc}" fi elif [[ -f "${bash_profile}" ]]; then grep -qF "${path_line}" "${bash_profile}" || echo "${path_line}" >> "${bash_profile}" grep -qF "${fnm_line}" "${bash_profile}" || echo "${fnm_line}" >> "${bash_profile}" if [[ "${FORCE_HTTPS}" = "1" ]]; then grep -qF "${https_line}" "${bash_profile}" || echo "${https_line}" >> "${bash_profile}" fi else info "add to your shell config:" info " ${path_line}" info " ${fnm_line}" if [[ "${FORCE_HTTPS}" = "1" ]]; then info " ${https_line}" fi fi } main() { info "starting macOS dev install" ensure_brew ensure_fzf ensure_fnm_and_node check_github_ssh clone_or_update if [[ "${JAZZ_AVAILABLE}" = "0" ]]; then install_release_fallback ensure_shell_setup info "" info "done." info "flow: ${FLOW_DIR}" info "jazz2: (skipped)" info "bin: ${BIN_DIR}" info "restart your shell, then run: f --help" return fi ensure_rust write_cargo_patch build_and_link ensure_shell_setup info "" info "done." info "flow: ${FLOW_DIR}" info "jazz2: ${JAZZ_DIR}" info "bin: ${BIN_DIR}" info "restart your shell, then run: f --help" } main "$@" ================================================ FILE: scripts/install.sh ================================================ #!/bin/sh # Allow `curl ... | sh` while still running the installer in bash. if [ -z "${BASH_VERSION:-}" ]; then if ! command -v bash >/dev/null 2>&1; then echo "flow installer: bash is required. Install bash, then rerun the installer." >&2 exit 1 fi case "${0:-}" in sh|-sh|dash|-dash|*/sh|*/dash) tmp="$(mktemp "${TMPDIR:-/tmp}/flow-install.XXXXXX.bash")" || { echo "flow installer: failed to create temp file" >&2 exit 1 } cat > "${tmp}" FLOW_INSTALL_SCRIPT_TMP="${tmp}" exec bash "${tmp}" "$@" ;; *) exec bash "$0" "$@" ;; esac fi set -euo pipefail if [[ -n "${FLOW_INSTALL_SCRIPT_TMP:-}" ]]; then trap 'rm -f "${FLOW_INSTALL_SCRIPT_TMP}"' EXIT fi # Installs flow + f to the current user. Usage: # curl -fsSL https://myflow.sh/install.sh | sh # curl -fsSL https://myflow.sh/install.sh | bash # Customize with: # FLOW_INSTALL_ROOT=/usr/local # overrides install prefix (default: ~/.local) # FLOW_BIN_DIR=/usr/local/bin # overrides bin dir (defaults to <root>/bin) # FLOW_VERSION=<tag> # release version to fetch (default: latest release) # FLOW_REF=<git ref> # fallback git ref for source build (default: main) # FLOW_REPO_URL=<repo url> # override repo (default: https://github.com/nikivdev/flow) # FLOW_RELEASE_BASE=<base url> # override release base (default: GitHub releases) # FLOW_BINARY_URL=<url> # skip build; download a prebuilt f binary # FLOW_REGISTRY_URL=<url> # install from Flow registry (e.g., https://myflow.sh) # FLOW_REGISTRY_PACKAGE=<name> # registry package name (default: flow) # FLOW_INSTALL_LIN=0 # skip installing the lin helper binary # FLOW_BOOTSTRAP_TOOLS="rise seq seqd" # install additional tools via `f install` after flow # FLOW_BOOTSTRAP_INSTALL_PARM=1 # auto-install parm before tool bootstrap # FLOW_NO_RELEASE=1 # force source build even if a release exists # FLOW_DEV=1 # dev install: clone to ~/code/org/1f/flow with jazz2 # FLOW_SKIP_DEPS=1 # skip installing dependencies (brew, fnm, node, bun, rust) REPO_URL="${FLOW_REPO_URL:-https://github.com/nikivdev/flow}" JAZZ_REPO_URL="${FLOW_JAZZ_URL:-https://github.com/garden-co/jazz2}" REF="${FLOW_REF:-main}" INSTALL_LIN="${FLOW_INSTALL_LIN:-1}" REGISTRY_URL="${FLOW_REGISTRY_URL:-}" REGISTRY_PACKAGE="${FLOW_REGISTRY_PACKAGE:-flow}" DEV_INSTALL="${FLOW_DEV:-}" SKIP_DEPS="${FLOW_SKIP_DEPS:-}" RELEASE_ONLY="${FLOW_RELEASE_ONLY:-}" BOOTSTRAP_TOOLS="${FLOW_BOOTSTRAP_TOOLS:-rise seq seqd}" BOOTSTRAP_INSTALL_PARM="${FLOW_BOOTSTRAP_INSTALL_PARM:-1}" FLOW_INSTALLED=0 RESOLVED_VERSION="" OS_NAME="" ARCH_NAME="" OWNER="" REPO_NAME="" # Dev install paths DEV_BASE="$HOME/code/org/1f" DEV_FLOW_DIR="$DEV_BASE/flow" DEV_JAZZ_DIR="$DEV_BASE/jazz2" fail() { echo "flow installer: $*" >&2 exit 1 } info() { echo "flow installer: $*" } # ============================================================================= # Dependency Installation # ============================================================================= install_homebrew() { if command -v brew &>/dev/null; then info "Homebrew already installed" return 0 fi info "Installing Homebrew..." /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Add brew to PATH for this session if [[ -f "/opt/homebrew/bin/brew" ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [[ -f "/usr/local/bin/brew" ]]; then eval "$(/usr/local/bin/brew shellenv)" fi } install_fnm() { if command -v fnm &>/dev/null; then info "fnm already installed" return 0 fi info "Installing fnm (Fast Node Manager)..." brew install fnm # Initialize fnm for this session eval "$(fnm env)" } install_node() { # Check if node is available via fnm if command -v fnm &>/dev/null; then if fnm list 2>/dev/null | grep -q "v"; then info "Node.js already installed via fnm" eval "$(fnm env)" return 0 fi info "Installing Node.js LTS via fnm..." fnm install --lts fnm default lts-latest eval "$(fnm env)" return 0 fi # Fallback: check if node exists if command -v node &>/dev/null; then info "Node.js already installed" return 0 fi fail "fnm not available and node not found" } install_bun() { if command -v bun &>/dev/null; then info "Bun already installed" return 0 fi info "Installing Bun..." curl -fsSL https://bun.sh/install | bash # Add bun to PATH for this session export BUN_INSTALL="$HOME/.bun" export PATH="$BUN_INSTALL/bin:$PATH" } install_rust() { if command -v cargo &>/dev/null; then info "Rust already installed" return 0 fi info "Installing Rust..." curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y # Add cargo to PATH for this session source "$HOME/.cargo/env" } install_gh() { if command -v gh &>/dev/null; then info "GitHub CLI already installed" return 0 fi info "Installing GitHub CLI..." brew install gh } install_fzf() { if command -v fzf &>/dev/null; then info "fzf already installed" return 0 fi info "Installing fzf..." brew install fzf } install_all_deps() { if [[ -n "${SKIP_DEPS}" ]]; then info "Skipping dependency installation (FLOW_SKIP_DEPS=1)" return 0 fi info "" info "=== Installing Dependencies ===" info "" install_homebrew install_fnm install_node install_bun install_rust install_gh install_fzf info "" info "=== Dependencies installed ===" info "" } resolve_paths() { local root="${FLOW_INSTALL_ROOT:-}" local bin="${FLOW_BIN_DIR:-}" if [[ -n "${root}" && -n "${bin}" ]]; then root="${root%/}" bin="${bin%/}" if [[ "${bin}" != "${root}/bin" ]]; then fail "FLOW_INSTALL_ROOT (${root}) and FLOW_BIN_DIR (${bin}) must align (expected ${root}/bin)." fi fi if [[ -z "${root}" && -z "${bin}" ]]; then root="$HOME/.local" bin="${root}/bin" elif [[ -z "${root}" ]]; then bin="${bin%/}" root="$(dirname "${bin}")" elif [[ -z "${bin}" ]]; then root="${root%/}" bin="${root}/bin" else root="${root%/}" bin="${bin%/}" fi INSTALL_ROOT="${root}" BIN_DIR="${bin}" } need_cmd() { command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" } detect_platform() { local uname_s uname_m uname_s="$(uname -s)" uname_m="$(uname -m)" case "${uname_s}" in Darwin) OS_NAME="darwin" ;; Linux) OS_NAME="linux" ;; *) fail "unsupported OS: ${uname_s}" ;; esac case "${uname_m}" in arm64|aarch64) ARCH_NAME="arm64" ;; x86_64|amd64) ARCH_NAME="amd64" ;; *) fail "unsupported architecture: ${uname_m}" ;; esac } parse_repo_url() { local repo="${REPO_URL%/}" repo="${repo%.git}" case "${repo}" in https://github.com/*/*) repo="${repo#https://github.com/}" OWNER="${repo%%/*}" REPO_NAME="${repo#*/}" if [[ -z "${OWNER}" || -z "${REPO_NAME}" || "${REPO_NAME}" == "${repo}" ]]; then fail "could not parse owner/repo from ${REPO_URL}" fi ;; *) fail "FLOW_REPO_URL must be a GitHub https URL when not using FLOW_BINARY_URL (got ${REPO_URL})" ;; esac } install_from_binary_url() { local url="$1" need_cmd curl info "Downloading flow from ${url}" mkdir -p "${BIN_DIR}" curl -fsSL "${url}" -o "${BIN_DIR}/f" chmod +x "${BIN_DIR}/f" } resolve_release_version() { if [[ -n "${FLOW_VERSION:-}" ]]; then RESOLVED_VERSION="${FLOW_VERSION}" return fi if ! command -v curl >/dev/null 2>&1; then return fi local api="https://api.github.com/repos/${OWNER}/${REPO_NAME}/releases/latest" local tag tag="$(curl -fsSL "${api}" 2>/dev/null | sed -n 's/ *\"tag_name\" *: *\"\\(.*\\)\".*/\\1/p' | head -n1 || true)" if [[ -n "${tag}" ]]; then RESOLVED_VERSION="${tag}" fi } resolve_registry_version() { if [[ -n "${FLOW_VERSION:-}" ]]; then RESOLVED_VERSION="${FLOW_VERSION}" return 0 fi local url="${REGISTRY_URL%/}/packages/${REGISTRY_PACKAGE}/latest.json" local manifest manifest="$(curl -fsSL "${url}" 2>/dev/null || true)" if [[ -z "${manifest}" ]]; then return 1 fi local version version="$(echo "${manifest}" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)" if [[ -z "${version}" ]]; then return 1 fi RESOLVED_VERSION="${version}" return 0 } install_from_registry() { local registry="${REGISTRY_URL%/}" if [[ -z "${registry}" ]]; then return 1 fi need_cmd curl if ! resolve_registry_version; then info "Failed to resolve latest registry version" return 1 fi local target="" if [[ "${OS_NAME}" == "darwin" ]]; then target="${ARCH_NAME}-apple-darwin" elif [[ "${OS_NAME}" == "linux" ]]; then target="${ARCH_NAME}-unknown-linux-gnu" else fail "unsupported OS for registry install: ${OS_NAME}" fi mkdir -p "${BIN_DIR}" local bins=("${REGISTRY_PACKAGE}") if [[ "${REGISTRY_PACKAGE}" == "flow" ]]; then bins=("f" "flow") if [[ "${INSTALL_LIN}" != "0" ]]; then bins+=("lin") fi fi local installed=0 for bin in "${bins[@]}"; do local url="${registry}/packages/${REGISTRY_PACKAGE}/${RESOLVED_VERSION}/${target}/${bin}" info "Downloading ${bin} from ${url}" if curl -fsSL "${url}" -o "${BIN_DIR}/${bin}"; then chmod +x "${BIN_DIR}/${bin}" installed=1 if [[ "${bin}" == "flow" ]]; then FLOW_INSTALLED=1 fi else info "Failed to download ${bin} from registry" fi done if [[ "${installed}" -eq 0 ]]; then info "Registry install failed" return 1 fi if [[ "${REGISTRY_PACKAGE}" == "flow" ]]; then ensure_aliases fi info "Installed ${REGISTRY_PACKAGE} ${RESOLVED_VERSION} from registry" return 0 } install_from_release() { local version="$1" local asset="flow_${version}_${OS_NAME}_${ARCH_NAME}.tar.gz" local base="${FLOW_RELEASE_BASE:-https://github.com/${OWNER}/${REPO_NAME}/releases/download}" local url="${base}/${version}/${asset}" need_cmd curl need_cmd tar info "Downloading release ${version} (${OS_NAME}/${ARCH_NAME})" local tmp_tar tmp_tar="$(mktemp)" || fail "failed to create temp file" if ! curl -fsSL "${url}" -o "${tmp_tar}"; then info "Release download failed; tried ${url}" rm -f "${tmp_tar}" return 1 fi local tmp_dir tmp_dir="$(mktemp -d)" || fail "failed to create temp dir" if ! tar -xzf "${tmp_tar}" -C "${tmp_dir}"; then info "Failed to unpack release tarball" rm -rf "${tmp_dir}" "${tmp_tar}" return 1 fi local extracted extracted="$(find "${tmp_dir}" -mindepth 1 -maxdepth 1 -type d | head -n1)" [[ -z "${extracted}" ]] && extracted="${tmp_dir}" mkdir -p "${BIN_DIR}" local copied=0 for bin in f flow lin; do if [[ -f "${extracted}/${bin}" ]]; then cp "${extracted}/${bin}" "${BIN_DIR}/${bin}" chmod +x "${BIN_DIR}/${bin}" copied=1 if [[ "${bin}" == "flow" ]]; then FLOW_INSTALLED=1 fi fi done rm -rf "${tmp_dir}" "${tmp_tar}" if [[ "${copied}" -eq 0 ]]; then info "Release tarball did not contain expected binaries" return 1 fi info "Installed release ${version} to ${BIN_DIR}" return 0 } download_source_tarball() { need_cmd curl need_cmd tar local dest="$1" local tar_url="https://codeload.github.com/${OWNER}/${REPO_NAME}/tar.gz/${REF}" info "Downloading source tarball ${tar_url}" mkdir -p "${dest}" curl -fsSL "${tar_url}" | tar -xz -C "${dest}" --strip-components=1 } install_from_source() { need_cmd cargo mkdir -p "${BIN_DIR}" local tmp tmp="$(mktemp -d)" || fail "failed to create temp dir" trap 'rm -rf "${tmp}"' EXIT download_source_tarball "${tmp}" info "Building flow from source with cargo (this may take a moment)..." local args=(install --locked --force --path "${tmp}" --root "${INSTALL_ROOT}" --bin f --bin flow) if [[ "${INSTALL_LIN}" != "0" ]]; then args+=(--bin lin) fi cargo "${args[@]}" if [[ -x "${BIN_DIR}/flow" ]]; then FLOW_INSTALLED=1 fi } ensure_aliases() { local target="${BIN_DIR}/f" [[ -x "${target}" ]] || fail "expected ${target} after install" if [[ "${FLOW_INSTALLED}" -eq 1 ]]; then return 0 fi ln -sf "${target}" "${BIN_DIR}/flow" } ensure_path_hint() { case ":$PATH:" in *":${BIN_DIR}:"*) ;; *) info "Add ${BIN_DIR} to your PATH, e.g. append: export PATH=\"${BIN_DIR}:\$PATH\"" ;; esac } install_parm_if_needed() { if command -v parm >/dev/null 2>&1; then return 0 fi if [[ "${BOOTSTRAP_INSTALL_PARM}" == "0" ]]; then return 1 fi if ! command -v curl >/dev/null 2>&1; then info "curl missing; cannot auto-install parm." return 1 fi info "Installing parm for robust GitHub fallback..." if curl -fsSL https://raw.githubusercontent.com/yhoundz/parm/master/scripts/install.sh | sh; then export PATH="$HOME/.local/bin:$HOME/bin:$PATH" return 0 fi info "parm install failed; continuing with registry/flox-only bootstrap." return 1 } bootstrap_core_tools() { local fbin="${BIN_DIR}/f" if [[ ! -x "${fbin}" ]]; then return 0 fi if [[ -z "${BOOTSTRAP_TOOLS}" || "${BOOTSTRAP_TOOLS}" == "0" || "${BOOTSTRAP_TOOLS}" == "false" ]]; then return 0 fi info "" info "=== Bootstrap Core Tools ===" info "" install_parm_if_needed || true local failures=0 local tool="" for tool in ${BOOTSTRAP_TOOLS}; do info "Bootstrapping ${tool}..." if "${fbin}" install "${tool}" --backend auto --bin-dir "${BIN_DIR}" --force; then : else info "WARN failed to bootstrap ${tool}. Retry later with: f install ${tool} --backend auto" failures=$((failures + 1)) fi done if [[ "${failures}" -eq 0 ]]; then info "Bootstrap complete: ${BOOTSTRAP_TOOLS}" else info "Bootstrap completed with ${failures} warning(s)." fi } # Dev install: clone repos to ~/code/org/1f/ and build from source install_dev() { # Install all dependencies first install_all_deps info "" info "=== Dev Install: Setting up in ${DEV_BASE} ===" info "" mkdir -p "${DEV_BASE}" # Clone or update jazz2 if [[ -d "${DEV_JAZZ_DIR}" ]]; then info "Jazz2 directory exists, updating..." (cd "${DEV_JAZZ_DIR}" && git pull --rebase) || true else info "Cloning jazz2 to ${DEV_JAZZ_DIR}..." git clone "${JAZZ_REPO_URL}" "${DEV_JAZZ_DIR}" fi # Clone or update flow if [[ -d "${DEV_FLOW_DIR}" ]]; then info "Flow directory exists, updating..." (cd "${DEV_FLOW_DIR}" && git pull --rebase) || true else info "Cloning flow to ${DEV_FLOW_DIR}..." git clone "${REPO_URL}" "${DEV_FLOW_DIR}" fi # Build info "Building flow from source..." (cd "${DEV_FLOW_DIR}" && cargo build --release) # Setup symlinks mkdir -p "${BIN_DIR}" ln -sf "${DEV_FLOW_DIR}/target/release/f" "${BIN_DIR}/f" if [[ -f "${DEV_FLOW_DIR}/target/release/flow" ]]; then ln -sf "${DEV_FLOW_DIR}/target/release/flow" "${BIN_DIR}/flow" else ln -sf "${DEV_FLOW_DIR}/target/release/f" "${BIN_DIR}/flow" fi if [[ "${INSTALL_LIN}" != "0" && -f "${DEV_FLOW_DIR}/target/release/lin" ]]; then ln -sf "${DEV_FLOW_DIR}/target/release/lin" "${BIN_DIR}/lin" fi info "Symlinked binaries to ${BIN_DIR}" info "" info "Dev install complete!" info " Flow: ${DEV_FLOW_DIR}" info " Jazz2: ${DEV_JAZZ_DIR}" info " Binaries: ${BIN_DIR}/f, ${BIN_DIR}/flow" } main() { resolve_paths detect_platform info "" info "=== Flow Installer ===" info "" # Dev install mode if [[ -n "${DEV_INSTALL}" ]]; then install_dev ensure_path_hint print_shell_setup info "" info "Done. Launch with \"flow --help\" or \"f --help\"." return fi parse_repo_url info "Installing to ${BIN_DIR}" if [[ -n "${FLOW_BINARY_URL:-}" ]]; then install_from_binary_url "${FLOW_BINARY_URL}" elif [[ -n "${REGISTRY_URL}" ]]; then if ! install_from_registry; then info "Registry install failed; falling back to release/source." REGISTRY_URL="" else ensure_aliases bootstrap_core_tools ensure_path_hint info "Done. Launch with \"flow --help\" or \"f --help\"." return fi elif [[ -z "${FLOW_NO_RELEASE:-}" ]]; then resolve_release_version if [[ -n "${RESOLVED_VERSION}" ]] && install_from_release "${RESOLVED_VERSION}"; then : else if [[ -n "${RELEASE_ONLY}" ]]; then fail "release not found or unavailable (FLOW_RELEASE_ONLY=1)" fi info "Falling back to source build (release not found or unavailable)." install_all_deps install_from_source fi else install_all_deps install_from_source fi ensure_aliases bootstrap_core_tools ensure_path_hint info "Done. Launch with \"flow --help\" or \"f --help\"." } print_shell_setup() { info "" info "=== Shell Setup ===" info "" info "Add these to your shell config:" info "" if [[ -f "$HOME/.config/fish/config.fish" ]]; then info "# Fish (~/.config/fish/config.fish):" info 'set -gx PATH $HOME/.local/bin $PATH' info '' info '# fnm (Node.js)' info 'fnm env | source' info '' info '# Bun' info 'set -gx BUN_INSTALL $HOME/.bun' info 'set -gx PATH $BUN_INSTALL/bin $PATH' info '' info '# Flow function' info 'function f' info ' if test -z "$argv[1]"' info ' ~/bin/f' info ' else' info ' ~/bin/f match $argv' info ' end' info 'end' else info "# Bash/Zsh (~/.bashrc or ~/.zshrc):" info 'export PATH="$HOME/.local/bin:$PATH"' info '' info '# fnm (Node.js)' info 'eval "$(fnm env)"' info '' info '# Bun' info 'export BUN_INSTALL="$HOME/.bun"' info 'export PATH="$BUN_INSTALL/bin:$PATH"' info '' info '# Flow function' info 'f() {' info ' if [ -z "$1" ]; then' info ' ~/bin/f' info ' else' info ' ~/bin/f match "$@"' info ' fi' info '}' fi } main "$@" ================================================ FILE: scripts/myflow-commit-session-smoke.sh ================================================ #!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: myflow-commit-session-smoke.sh [options] Verify that a commit is visible in myflow and optionally require attached AI sessions. Options: --repo-path PATH Git repo to inspect (default: current directory) --repo-slug OWNER/REPO Override repo slug (auto-detected from origin) --commit-sha SHA Commit to verify (default: HEAD of --repo-path) --api-base URL myflow API base (default: MYFLOW_URL or https://myflow.sh) --token TOKEN Auth token (default: MYFLOW_TOKEN or ~/.config/flow/auth.toml) --timeout SECONDS Poll timeout waiting for commit (default: 60) --require-sessions Fail if commit has zero attached sessions --skip-session-fetch Do not verify GET /api/sessions/:id for first session -h, --help Show this help EOF } REPO_PATH="${PWD}" REPO_SLUG="" COMMIT_SHA="" API_BASE="${MYFLOW_URL:-https://myflow.sh}" TOKEN="${MYFLOW_TOKEN:-}" TIMEOUT_SECS=60 REQUIRE_SESSIONS=0 SKIP_SESSION_FETCH=0 while [ "$#" -gt 0 ]; do case "$1" in --repo-path) REPO_PATH="$2" shift 2 ;; --repo-slug) REPO_SLUG="$2" shift 2 ;; --commit-sha) COMMIT_SHA="$2" shift 2 ;; --api-base) API_BASE="$2" shift 2 ;; --token) TOKEN="$2" shift 2 ;; --timeout) TIMEOUT_SECS="$2" shift 2 ;; --require-sessions) REQUIRE_SESSIONS=1 shift ;; --skip-session-fetch) SKIP_SESSION_FETCH=1 shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; esac done if [ ! -d "$REPO_PATH/.git" ]; then echo "repo path is not a git repo: $REPO_PATH" >&2 exit 2 fi if [ -z "$COMMIT_SHA" ]; then COMMIT_SHA="$(git -C "$REPO_PATH" rev-parse HEAD)" fi if [ -z "$REPO_SLUG" ]; then origin="$(git -C "$REPO_PATH" remote get-url origin 2>/dev/null || true)" if [ -n "$origin" ]; then if [[ "$origin" =~ ^git@github\.com:(.+)\.git$ ]]; then REPO_SLUG="${BASH_REMATCH[1]}" elif [[ "$origin" =~ ^git@github\.com:(.+)$ ]]; then REPO_SLUG="${BASH_REMATCH[1]}" elif [[ "$origin" =~ ^https?://github\.com/(.+)\.git$ ]]; then REPO_SLUG="${BASH_REMATCH[1]}" elif [[ "$origin" =~ ^https?://github\.com/(.+)$ ]]; then REPO_SLUG="${BASH_REMATCH[1]}" fi fi fi if [ -z "$REPO_SLUG" ]; then echo "failed to resolve repo slug; pass --repo-slug owner/repo" >&2 exit 2 fi if [ -z "$TOKEN" ] && [ -f "$HOME/.config/flow/auth.toml" ]; then TOKEN="$( python3 - "$HOME/.config/flow/auth.toml" <<'PY' import pathlib import sys p = pathlib.Path(sys.argv[1]) if not p.exists(): print("") raise SystemExit(0) try: import tomllib except Exception: print("") raise SystemExit(0) try: data = tomllib.loads(p.read_text(encoding="utf-8")) except Exception: print("") raise SystemExit(0) token = data.get("token") print(token if isinstance(token, str) else "") PY )" fi if [ -z "$TOKEN" ]; then echo "missing token; set MYFLOW_TOKEN or pass --token" >&2 exit 2 fi API_BASE="${API_BASE%/}" ENCODED_REPO="$( python3 - "$REPO_SLUG" <<'PY' import sys, urllib.parse print(urllib.parse.quote(sys.argv[1], safe="")) PY )" deadline=$(( $(date +%s) + TIMEOUT_SECS )) commit_line="" payload="" echo "[myflow-smoke] repo=${REPO_SLUG} commit=${COMMIT_SHA} api=${API_BASE}" while [ "$(date +%s)" -le "$deadline" ]; do payload="$( curl -fsS \ --max-time 10 \ -H "Authorization: Bearer ${TOKEN}" \ "${API_BASE}/api/commits?repo=${ENCODED_REPO}" \ || true )" if [ -n "$payload" ]; then set +e commit_line="$( python3 - "$COMMIT_SHA" "$REQUIRE_SESSIONS" <<'PY' <<<"$payload" import json import sys target = sys.argv[1].lower() require_sessions = sys.argv[2] == "1" try: data = json.load(sys.stdin) except Exception: raise SystemExit(5) if not isinstance(data, list): raise SystemExit(5) found = None for item in data: if not isinstance(item, dict): continue sha = str(item.get("commitSha", "")).lower() if sha == target or sha.startswith(target): found = item break if not found: raise SystemExit(3) sessions = found.get("sessions") or [] if not isinstance(sessions, list): sessions = [] if require_sessions and len(sessions) == 0: raise SystemExit(4) window = found.get("sessionWindow") or {} mode = "" if isinstance(window, dict): mode = str(window.get("mode", "")) first_session = "" if sessions and isinstance(sessions[0], dict): first_session = str(sessions[0].get("sessionId", "")) print(f"{found.get('commitSha','')}\t{len(sessions)}\t{mode}\t{first_session}") PY )" status=$? set -e case "$status" in 0) break ;; 3) ;; 4) echo "[myflow-smoke] commit found but sessions=0 and --require-sessions is set" >&2 exit 1 ;; *) ;; esac fi sleep 2 done if [ -z "$commit_line" ]; then echo "[myflow-smoke] commit not found in myflow within ${TIMEOUT_SECS}s" >&2 exit 1 fi IFS=$'\t' read -r found_sha session_count window_mode first_session_id <<<"$commit_line" echo "[myflow-smoke] found commit=${found_sha} sessions=${session_count} sessionWindow.mode=${window_mode:-<none>}" if [ "$SKIP_SESSION_FETCH" -eq 0 ] && [ -n "$first_session_id" ]; then encoded_session="$( python3 - "$first_session_id" <<'PY' import sys, urllib.parse print(urllib.parse.quote(sys.argv[1], safe="")) PY )" curl -fsS \ --max-time 10 \ -H "Authorization: Bearer ${TOKEN}" \ "${API_BASE}/api/sessions/${encoded_session}" >/dev/null echo "[myflow-smoke] verified first session fetch: ${first_session_id}" fi echo "[myflow-smoke] ok" ================================================ FILE: scripts/package-release.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Build and package flow binaries into a tar.gz release artifact. # Usage: # FLOW_VERSION=v0.1.0 CODESIGN_IDENTITY="Developer ID Application: Example (TEAMID)" scripts/package-release.sh # # Outputs: # dist/flow-<version>-<os>-<arch>.tar.gz # dist/flow-<version>-<os>-<arch>.tar.gz.sha256 # Contents: # f (binary), flow (binary), lin (binary) # Notes: # - macOS: if CODESIGN_IDENTITY is set, f, flow, and lin are codesigned (--timestamp --options runtime). # - Build is local-only; run on each target platform (macOS arm64/x86_64, Linux x86_64/aarch64). ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="${ROOT_DIR}/dist" PROFILE=release fail() { echo "package-release: $*" >&2 exit 1 } info() { echo "package-release: $*" } detect_platform() { local os uname_s arch uname_m uname_s="$(uname -s)" uname_m="$(uname -m)" case "${uname_s}" in Darwin) os="darwin" ;; Linux) os="linux" ;; *) fail "unsupported OS: ${uname_s}" ;; esac case "${uname_m}" in arm64|aarch64) arch="arm64" ;; x86_64|amd64) arch="amd64" ;; *) fail "unsupported arch: ${uname_m}" ;; esac OS_NAME="${os}" ARCH_NAME="${arch}" } resolve_version() { if [[ -n "${FLOW_VERSION:-}" ]]; then VERSION="${FLOW_VERSION}" return fi if command -v git >/dev/null 2>&1; then VERSION="$(git -C "${ROOT_DIR}" describe --tags --always --dirty 2>/dev/null || true)" fi VERSION="${VERSION:-dev}" } codesign_if_requested() { local bin="$1" if [[ "${OS_NAME}" != "darwin" ]]; then return fi if [[ -z "${CODESIGN_IDENTITY:-}" ]]; then info "No CODESIGN_IDENTITY set; skipping codesign for ${bin}" return fi if ! command -v codesign >/dev/null 2>&1; then fail "codesign not found; install Xcode command line tools to sign" fi info "Codesigning ${bin}" codesign --force --timestamp --options runtime --sign "${CODESIGN_IDENTITY}" "${bin}" } checksum() { local file="$1" if command -v shasum >/dev/null 2>&1; then shasum -a 256 "${file}" elif command -v sha256sum >/dev/null 2>&1; then sha256sum "${file}" else fail "neither shasum nor sha256sum found for checksumming" fi } main() { detect_platform resolve_version info "Building flow (version ${VERSION}, ${OS_NAME}/${ARCH_NAME})" cargo build --locked --release --bin f --bin flow --bin lin local stage="${DIST_DIR}/flow_${VERSION}_${OS_NAME}_${ARCH_NAME}" local target_dir="${ROOT_DIR}/target/${PROFILE}" rm -rf "${stage}" mkdir -p "${stage}" cp "${target_dir}/f" "${stage}/f" cp "${target_dir}/flow" "${stage}/flow" cp "${target_dir}/lin" "${stage}/lin" codesign_if_requested "${stage}/f" codesign_if_requested "${stage}/flow" codesign_if_requested "${stage}/lin" mkdir -p "${DIST_DIR}" local tarball="${DIST_DIR}/flow_${VERSION}_${OS_NAME}_${ARCH_NAME}.tar.gz" tar -C "${DIST_DIR}" -czf "${tarball}" "flow_${VERSION}_${OS_NAME}_${ARCH_NAME}" checksum "${tarball}" > "${tarball}.sha256" info "Built ${tarball}" info "Checksum written to ${tarball}.sha256" info "Upload these to the GitHub release for ${VERSION} so install.sh can fetch them." } main "$@" ================================================ FILE: scripts/pre-push-guard.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_root" remote_name="${1:-origin}" zero_sha="0000000000000000000000000000000000000000" should_verify_pinned_origin=0 range_changes_vendor_lock() { local local_ref="$1" local local_sha="$2" local remote_ref="$3" local remote_sha="$4" local branch_name="" local base_ref="" [[ "$local_ref" == refs/heads/* ]] || return 1 [[ "$local_sha" != "$zero_sha" ]] || return 1 if [[ "$remote_sha" != "$zero_sha" ]] && git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then base_ref="$remote_sha" elif [[ "$remote_ref" == refs/heads/* ]]; then branch_name="${remote_ref#refs/heads/}" if git rev-parse --verify "refs/remotes/$remote_name/$branch_name" >/dev/null 2>&1; then base_ref="refs/remotes/$remote_name/$branch_name" fi fi if [[ -z "$base_ref" ]] && git rev-parse --verify "refs/remotes/$remote_name/main" >/dev/null 2>&1; then base_ref="$(git merge-base "$local_sha" "refs/remotes/$remote_name/main" 2>/dev/null || true)" fi if [[ -n "$base_ref" ]]; then git diff --quiet "$base_ref...$local_sha" -- vendor.lock.toml return $? fi # Fallback for brand-new remotes/branches with no useful remote base yet. git diff-tree --quiet --no-commit-id -r "$local_sha" -- vendor.lock.toml } while read -r local_ref local_sha remote_ref remote_sha; do [[ -n "${local_ref:-}" ]] || continue if ! range_changes_vendor_lock "$local_ref" "$local_sha" "$remote_ref" "$remote_sha"; then should_verify_pinned_origin=1 break fi done if [[ "$should_verify_pinned_origin" == "1" ]]; then echo "pre-push: vendor.lock.toml changed in pushed refs; verifying pinned vendor commit is published" "$repo_root/scripts/vendor/vendor-repo.sh" verify-pinned-origin fi ================================================ FILE: scripts/publish-release.sh ================================================ #!/usr/bin/env bash set -euo pipefail if [[ $# -lt 2 ]]; then echo "Usage: $0 <ssh-host> <tarball>" >&2 echo "Env: REMOTE_ROOT=/var/www/flow" >&2 exit 1 fi SSH_HOST="$1" TARBALL="$2" if [[ ! -f "${TARBALL}" ]]; then echo "publish-release: tarball not found: ${TARBALL}" >&2 exit 1 fi if ! command -v ssh >/dev/null 2>&1; then echo "publish-release: ssh is required." >&2 exit 1 fi if ! command -v scp >/dev/null 2>&1; then echo "publish-release: scp is required." >&2 exit 1 fi FILENAME="$(basename "${TARBALL}")" if [[ "${FILENAME}" =~ ^flow_(.+)_darwin_arm64\.tar\.gz$ ]]; then VERSION="${BASH_REMATCH[1]}" else echo "publish-release: expected flow_<version>_darwin_arm64.tar.gz" >&2 exit 1 fi SHA_FILE="${TARBALL}.sha256" if [[ ! -f "${SHA_FILE}" ]]; then if command -v shasum >/dev/null 2>&1; then shasum -a 256 "${TARBALL}" > "${SHA_FILE}" elif command -v sha256sum >/dev/null 2>&1; then sha256sum "${TARBALL}" > "${SHA_FILE}" else echo "publish-release: need shasum or sha256sum to create checksum" >&2 exit 1 fi fi REMOTE_ROOT="${REMOTE_ROOT:-/var/www/flow}" REMOTE_VERSION_DIR="${REMOTE_ROOT}/${VERSION}" REMOTE_LATEST_DIR="${REMOTE_ROOT}/latest" LATEST_NAME="flow_latest_darwin_arm64.tar.gz" LATEST_SHA="${LATEST_NAME}.sha256" ssh "${SSH_HOST}" "mkdir -p '${REMOTE_VERSION_DIR}' '${REMOTE_LATEST_DIR}'" scp "${TARBALL}" "${SSH_HOST}:${REMOTE_VERSION_DIR}/${FILENAME}" scp "${SHA_FILE}" "${SSH_HOST}:${REMOTE_VERSION_DIR}/${FILENAME}.sha256" ssh "${SSH_HOST}" "ln -sf '${REMOTE_VERSION_DIR}/${FILENAME}' '${REMOTE_LATEST_DIR}/${LATEST_NAME}'" ssh "${SSH_HOST}" "ln -sf '${REMOTE_VERSION_DIR}/${FILENAME}.sha256' '${REMOTE_LATEST_DIR}/${LATEST_SHA}'" echo "publish-release: uploaded ${FILENAME} to ${SSH_HOST}:${REMOTE_VERSION_DIR}" echo "publish-release: latest -> ${REMOTE_LATEST_DIR}/${LATEST_NAME}" ================================================ FILE: scripts/release.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" SETUP=1 if [[ "${1:-}" == "--no-setup" ]]; then SETUP=0 shift elif [[ "${1:-}" == "--setup" ]]; then SETUP=1 shift fi if ! command -v infra >/dev/null 2>&1; then echo "release: infra CLI not found. Build it with:" >&2 echo " (cd /path/to/infra/cli && cargo build --release && cp target/release/infra ~/.local/bin/infra)" >&2 exit 1 fi bash "${ROOT_DIR}/scripts/package-release.sh" tarball="$(ls -t "${ROOT_DIR}"/dist/flow_*_darwin_arm64.tar.gz 2>/dev/null | head -n1 || true)" if [[ -z "${tarball}" ]]; then echo "release: no darwin/arm64 tarball found in dist/" >&2 exit 1 fi cmd=(infra release publish "${tarball}" --path "${ROOT_DIR}") if [[ "${SETUP}" -eq 1 ]]; then cmd+=(--setup) fi "${cmd[@]}" ================================================ FILE: scripts/remote-hub-setup.sh ================================================ #!/usr/bin/env bash set -euo pipefail if [ $# -lt 1 ]; then echo "Usage: $0 <ssh-host> [config-path]" >&2 exit 1 fi SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" SSH_HOST="$1" CONFIG_PATH="${2:-$HOME/.config/flow/config.toml}" if [ ! -f "${CONFIG_PATH}" ]; then echo "Config file not found at ${CONFIG_PATH}" >&2 exit 1 fi REMOTE_HOME="$(ssh "${SSH_HOST}" 'printf %s "$HOME"')" REMOTE_ROOT="${REMOTE_ROOT:-${REMOTE_HOME}/flow-hub}" REMOTE_PORT="${REMOTE_PORT:-9050}" REMOTE_BIN_DIR="${REMOTE_ROOT}/bin" REMOTE_CONFIG_DIR="${REMOTE_ROOT}/config" REMOTE_SYNC_DIR="${REMOTE_ROOT}/sync" REMOTE_SERVICE_USER="${REMOTE_SERVICE_USER:-$(ssh "${SSH_HOST}" 'whoami')}" echo "Building flow CLI and daemon (release profile)..." FLOW_PROFILE=release "${ROOT_DIR}/scripts/deploy.sh" >/dev/null FLOW_BIN="${ROOT_DIR}/target/release/f" echo "Copying binary and config to ${SSH_HOST}:${REMOTE_ROOT}" ssh "${SSH_HOST}" "mkdir -p ${REMOTE_BIN_DIR} ${REMOTE_CONFIG_DIR}" scp "${FLOW_BIN}" "${SSH_HOST}:${REMOTE_BIN_DIR}/f" scp "${CONFIG_PATH}" "${SSH_HOST}:${REMOTE_CONFIG_DIR}/flow.toml" if [ -n "${REMOTE_SYNC_PATHS:-}" ]; then ssh "${SSH_HOST}" "mkdir -p ${REMOTE_SYNC_DIR}" IFS=':' read -ra SYNC_PATHS <<<"${REMOTE_SYNC_PATHS}" for path in "${SYNC_PATHS[@]}"; do [ -z "${path}" ] && continue if [ ! -e "${path}" ]; then echo "Skipping sync path (missing): ${path}" >&2 continue fi echo "Syncing ${path} -> ${SSH_HOST}:${REMOTE_SYNC_DIR}" scp -r "${path}" "${SSH_HOST}:${REMOTE_SYNC_DIR}" done fi SERVICE_UNIT="[Unit] Description=Remote flow hub After=network.target [Service] Type=simple Environment=FLOW_CONFIG=${REMOTE_CONFIG_DIR}/flow.toml ExecStart=${REMOTE_BIN_DIR}/f daemon --host 0.0.0.0 --port ${REMOTE_PORT} Restart=always RestartSec=5 User=${REMOTE_SERVICE_USER} WorkingDirectory=${REMOTE_ROOT} [Install] WantedBy=multi-user.target" echo "Configuring systemd service on ${SSH_HOST}" ssh "${SSH_HOST}" "sudo bash -c 'cat <<\"EOF\" > /etc/systemd/system/flowd.service ${SERVICE_UNIT} EOF systemctl daemon-reload systemctl enable --now flowd.service'" echo "Remote hub deployed. Use tailscale to reach ${SSH_HOST}:${REMOTE_PORT}." ================================================ FILE: scripts/rl_signal_summary.py ================================================ #!/usr/bin/env python3 """Summarize flow RL signal JSONL output.""" from __future__ import annotations import argparse import json from collections import Counter, defaultdict from pathlib import Path def percentile(sorted_values: list[int], pct: float) -> int: if not sorted_values: return 0 idx = int((len(sorted_values) - 1) * pct) return sorted_values[max(0, min(idx, len(sorted_values) - 1))] def main() -> int: parser = argparse.ArgumentParser(description="Summarize flow RL signal JSONL") parser.add_argument( "path", nargs="?", default="out/logs/flow_rl_signals.jsonl", help="Path to flow RL signal JSONL", ) parser.add_argument("--last", type=int, default=0, help="Only process last N lines") args = parser.parse_args() path = Path(args.path).expanduser().resolve() if not path.exists(): print(f"missing file: {path}") return 1 lines = path.read_text(encoding="utf-8", errors="replace").splitlines() if args.last > 0: lines = lines[-args.last :] total = 0 by_event = Counter() by_error = Counter() durations: defaultdict[str, list[int]] = defaultdict(list) for raw in lines: raw = raw.strip() if not raw: continue try: row = json.loads(raw) except json.JSONDecodeError: continue if not isinstance(row, dict): continue total += 1 event = str(row.get("event_type", "unknown")) by_event[event] += 1 err_cls = row.get("error_class") if err_cls: by_error[str(err_cls)] += 1 dur = row.get("duration_ms") if isinstance(dur, int) and dur >= 0: durations[event].append(dur) print(f"file: {path}") print(f"rows: {total}") print("") print("event counts:") for event, count in by_event.most_common(): print(f" {event}: {count}") if by_error: print("") print("error classes:") for err, count in by_error.most_common(): print(f" {err}: {count}") if durations: print("") print("duration ms:") for event, values in sorted(durations.items()): values.sort() p50 = percentile(values, 0.50) p95 = percentile(values, 0.95) p99 = percentile(values, 0.99) print(f" {event}: p50={p50} p95={p95} p99={p99} n={len(values)}") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/run-health-checks.mjs ================================================ import fs from "node:fs/promises" import path from "node:path" import { execFileSync } from "node:child_process" import { deflateRawSync } from "node:zlib" const args = process.argv.slice(2) let configPath = ".ai/health-checks.json" for (let i = 0; i < args.length; i += 1) { if (args[i] === "--config" && args[i + 1]) { configPath = args[i + 1] i += 1 } } const resolvedPath = path.resolve(configPath) let config try { const raw = await fs.readFile(resolvedPath, "utf8") config = JSON.parse(raw) } catch (err) { if (err && err.code === "ENOENT") { console.log(`No health checks configured at ${configPath}; skipping.`) process.exit(0) } throw err } const checks = Array.isArray(config.checks) ? config.checks : [] if (checks.length === 0) { console.log(`No health checks configured in ${configPath}; skipping.`) process.exit(0) } const defaultBaseUrl = resolveDefaultBaseUrl(config) const defaultTimeoutMs = typeof config.timeout_ms === "number" ? config.timeout_ms : 10000 console.log(`Running ${checks.length} health check(s) from ${configPath}`) let failures = 0 for (const check of checks) { try { if (check.type === "gitedit-share") { const results = await runGiteditShareCheck(check, { baseUrl: resolveBaseUrl(check, defaultBaseUrl), timeoutMs: resolveTimeoutMs(check, defaultTimeoutMs), }) for (const result of results) { if (result.ok) { console.log(`OK: ${result.name}`) } else { failures += 1 console.log(`FAIL: ${result.name} - ${result.message}`) } } continue } if (check.type === "http") { const result = await runHttpCheck(check, { baseUrl: resolveBaseUrl(check, defaultBaseUrl, false), timeoutMs: resolveTimeoutMs(check, defaultTimeoutMs), }) if (result.ok) { console.log(`OK: ${result.name}`) } else { failures += 1 console.log(`FAIL: ${result.name} - ${result.message}`) } continue } failures += 1 console.log(`FAIL: ${check.name || "unnamed"} - unknown type`) } catch (err) { failures += 1 const name = check.name || check.type || "health check" console.log( `FAIL: ${name} - ${err instanceof Error ? err.message : String(err)}`, ) } } if (failures > 0) { console.log(`${failures} health check(s) failed.`) process.exit(1) } else { console.log("All health checks passed.") } function resolveDefaultBaseUrl(configValue) { const raw = configValue.baseUrl ?? configValue.base_url ?? process.env.HEALTH_BASE_URL if (!raw) return "" return expandEnv(String(raw)) } function resolveBaseUrl(check, fallback, required = true) { const raw = check.baseUrl ?? check.base_url ?? fallback if (!raw && required) { throw new Error("Missing baseUrl for health check.") } if (!raw) return "" return expandEnv(String(raw)) } function resolveTimeoutMs(check, fallback) { return typeof check.timeout_ms === "number" ? check.timeout_ms : fallback } function expandEnv(value) { if (typeof value !== "string") return value return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (match, name) => { const envValue = process.env[name] if (envValue === undefined) { throw new Error(`Missing environment variable: ${name}`) } return envValue }) } async function runHttpCheck(check, { baseUrl, timeoutMs }) { const name = check.name || check.url || "http check" const rawUrl = expandEnv(check.url ?? "") if (!rawUrl) { return { ok: false, name, message: "Missing url" } } const resolvedUrl = resolveUrl(rawUrl, baseUrl) const expectedStatuses = normalizeStatuses(check.expect_status ?? 200) const contains = normalizeContains(check.contains) const method = check.method ? String(check.method).toUpperCase() : "GET" const headers = check.headers && typeof check.headers === "object" ? check.headers : undefined const response = await fetchWithTimeout(resolvedUrl, { method, headers }, timeoutMs) if (!expectedStatuses.includes(response.status)) { return { ok: false, name, message: `Expected ${expectedStatuses.join(",")}, got ${response.status}`, } } if (contains.length > 0) { const body = await response.text() for (const needle of contains) { if (!body.includes(needle)) { return { ok: false, name, message: `Missing text: ${needle}` } } } } return { ok: true, name } } async function runGiteditShareCheck(check, { baseUrl, timeoutMs }) { const name = check.name || "gitedit share" const owner = check.owner || "" const repo = check.repo || "" const commitRef = check.commit || "HEAD" if (!owner || !repo) { return [ { ok: false, name, message: "Missing owner or repo", }, ] } const commitSha = resolveCommitSha(commitRef) const payload = buildSharePayload({ owner, repo, commitSha }) const hash = encodeSharePayload(payload) const apiUrl = new URL(`/api/mirrors/share/${hash}`, baseUrl).toString() const pageUrl = new URL(`/${hash}`, baseUrl).toString() const results = [] if (check.check_api !== false) { const response = await fetchWithTimeout(apiUrl, {}, timeoutMs) if (!response.ok) { results.push({ ok: false, name: `${name} api`, message: `HTTP ${response.status}`, }) } else { const data = await response.json().catch(() => null) if (!data || data.commit?.commit_sha !== commitSha) { results.push({ ok: false, name: `${name} api`, message: "Unexpected response payload", }) } else { results.push({ ok: true, name: `${name} api` }) } } } if (check.check_page !== false) { const response = await fetchWithTimeout(pageUrl, {}, timeoutMs) if (!response.ok) { results.push({ ok: false, name: `${name} page`, message: `HTTP ${response.status}`, }) } else { results.push({ ok: true, name: `${name} page` }) } } return results } function resolveCommitSha(ref) { try { return execFileSync("git", ["rev-parse", ref], { encoding: "utf8", }).trim() } catch (err) { if (/^[0-9a-f]{7,40}$/i.test(ref)) return ref throw err } } function buildSharePayload({ owner, repo, commitSha }) { return { v: 1, owner, repo, commit: { commit_sha: commitSha, commit_message: null, author_name: null, author_email: null, branch: null, ref: null, event: "commit", source: "flow-cli", session_hash: null, ai_sessions: [], received_at: new Date().toISOString(), }, } } function encodeSharePayload(payload) { const json = JSON.stringify(payload) const compressed = deflateRawSync(Buffer.from(json)) const b64 = compressed.toString("base64") return b64.replace(/\+/g, "-").replace(/\//g, "_") } function normalizeStatuses(value) { if (Array.isArray(value)) { return value.map((item) => Number(item)).filter((item) => !Number.isNaN(item)) } const numberValue = Number(value) return Number.isNaN(numberValue) ? [200] : [numberValue] } function normalizeContains(value) { if (!value) return [] if (Array.isArray(value)) return value.map(String) return [String(value)] } function resolveUrl(url, baseUrl) { if (/^https?:\/\//i.test(url)) return url if (!baseUrl) { throw new Error(`Relative url requires baseUrl: ${url}`) } return new URL(url, baseUrl).toString() } async function fetchWithTimeout(url, options, timeoutMs) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { return await fetch(url, { ...options, signal: controller.signal }) } finally { clearTimeout(timeout) } } ================================================ FILE: scripts/run-repos.sh ================================================ #!/usr/bin/env bash set -euo pipefail RUN_ROOT_DEFAULT="$HOME/run" expand_path() { local raw="$1" case "$raw" in "~") printf '%s\n' "$HOME" ;; "~/"*) printf '%s/%s\n' "$HOME" "${raw#~/}" ;; *) printf '%s\n' "$raw" ;; esac } RUN_ROOT="$(expand_path "${RUN_ROOT:-$RUN_ROOT_DEFAULT}")" usage() { cat <<'USAGE' Usage: run-repos.sh root run-repos.sh ensure run-repos.sh list run-repos.sh load <name> <repo-ssh-url> [branch] run-repos.sh sync [name] run-repos.sh task <name> <flow-task> [args...] run-repos.sh r <flow-task> [args...] run-repos.sh ri <flow-task> [args...] run-repos.sh rp <project> <flow-task> [args...] run-repos.sh rip <project> <flow-task> [args...] run-repos.sh exec <name> <repo-ssh-url> [--branch <branch>] <flow-task> [args...] Environment: RUN_ROOT Run repo root (default: ~/run) RUN_AUTO_SYNC If set to 1, run-repos.sh task auto-syncs git repos before running task USAGE } ensure_root() { mkdir -p "$RUN_ROOT" } repo_dir() { local name="$1" printf '%s/%s\n' "$RUN_ROOT" "$name" } is_git_repo() { local dir="$1" [ -d "$dir/.git" ] } validate_relative_path() { local rel="$1" local label="$2" if [ -z "$rel" ]; then echo "ERROR: $label cannot be empty" exit 1 fi case "$rel" in /*) echo "ERROR: $label must be relative to \$RUN_ROOT (got absolute path: $rel)" exit 1 ;; ..|../*|*/..|*/../*) echo "ERROR: $label must not contain '..' segments: $rel" exit 1 ;; esac } sync_git_repo() { local dir="$1" if ! is_git_repo "$dir"; then echo "[run] skip sync (not git): $dir" return 0 fi local branch="" branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" echo "[run] syncing: $dir" git -C "$dir" fetch --all --prune if [ -n "$branch" ] && git -C "$dir" show-ref --verify --quiet "refs/remotes/origin/$branch"; then git -C "$dir" pull --ff-only origin "$branch" else git -C "$dir" pull --ff-only || true fi } print_repo_row() { local name="$1" local dir="$2" if is_git_repo "$dir"; then local remote="" local branch="" remote="$(git -C "$dir" remote get-url origin 2>/dev/null || true)" branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" echo "$name | git | ${branch:-?} | ${remote:-no-origin} | $dir" else echo "$name | no-git | - | - | $dir" fi } display_name_for_dir() { local dir="$1" if [ "$dir" = "$RUN_ROOT" ]; then printf 'root' return 0 fi printf '%s' "${dir#$RUN_ROOT/}" } run_task_in_dir() { local dir="$1" local label="$2" shift 2 if [ ! -d "$dir" ]; then echo "ERROR: run repo/project not found: $dir" exit 1 fi if [ ! -f "$dir/flow.toml" ]; then echo "ERROR: no flow.toml in: $dir" exit 1 fi if [ "${RUN_AUTO_SYNC:-0}" = "1" ] && is_git_repo "$dir"; then sync_git_repo "$dir" fi local config_path="$dir/flow.toml" echo "[run] $label -> f run --config $config_path $*" ( cd "$dir" f run --config "$config_path" "$@" ) } resolve_project_dir() { local project="$1" validate_relative_path "$project" "project" local direct local internal direct="$(repo_dir "$project")" internal="$(repo_dir "i/$project")" if [ "$project" != i/* ] && [ -d "$direct" ] && [ -d "$internal" ]; then echo "ERROR: project '$project' is ambiguous." echo "Use explicit path: 'i/$project' for internal, or '$project' for public." exit 1 fi if [ -d "$direct" ]; then printf '%s\n' "$direct" return 0 fi if [ "$project" != i/* ] && [ -d "$internal" ]; then printf '%s\n' "$internal" return 0 fi echo "ERROR: project not found under \$RUN_ROOT:" echo " tried: $direct" if [ "$project" != i/* ]; then echo " tried: $internal" fi exit 1 } cmd_root() { echo "$RUN_ROOT" } cmd_ensure() { ensure_root mkdir -p "$RUN_ROOT/i" echo "[run] root ready: $RUN_ROOT" } cmd_list() { ensure_root local has_any=0 if [ -f "$RUN_ROOT/flow.toml" ]; then print_repo_row "root" "$RUN_ROOT" has_any=1 fi while IFS= read -r toml; do local dir local name dir="$(dirname "$toml")" [ "$dir" = "$RUN_ROOT" ] && continue name="${dir#$RUN_ROOT/}" print_repo_row "$name" "$dir" has_any=1 done < <(find "$RUN_ROOT" -mindepth 1 -maxdepth 6 -type f -name flow.toml 2>/dev/null | sort) if [ "$has_any" -eq 0 ]; then echo "[run] no run repos found in $RUN_ROOT" fi } cmd_load() { if [ "$#" -lt 2 ]; then echo "ERROR: load requires <name> <repo-ssh-url> [branch]" usage exit 1 fi local name="$1" validate_relative_path "$name" "name" local repo_url="$2" local branch="${3:-}" local dir dir="$(repo_dir "$name")" ensure_root if [ -e "$dir" ] && ! [ -d "$dir" ]; then echo "ERROR: target exists and is not a directory: $dir" exit 1 fi if is_git_repo "$dir"; then echo "[run] already loaded: $name ($dir)" sync_git_repo "$dir" return 0 fi if [ -d "$dir" ] && [ ! -d "$dir/.git" ]; then echo "ERROR: directory exists but is not a git repo: $dir" echo "Remove it manually or choose another run repo name." exit 1 fi if [ -n "$branch" ]; then echo "[run] cloning $repo_url (branch: $branch) -> $dir" git clone --branch "$branch" "$repo_url" "$dir" else echo "[run] cloning $repo_url -> $dir" git clone "$repo_url" "$dir" fi if [ ! -f "$dir/flow.toml" ]; then echo "WARN: cloned repo has no flow.toml: $dir" fi } cmd_sync() { ensure_root if [ "$#" -gt 0 ]; then local name="$1" validate_relative_path "$name" "name" local dir dir="$(repo_dir "$name")" if [ ! -d "$dir" ]; then echo "ERROR: run repo not found: $dir" exit 1 fi sync_git_repo "$dir" return 0 fi local found=0 while IFS= read -r git_dir; do [ -n "$git_dir" ] || continue local repo repo="$(dirname "$git_dir")" found=1 sync_git_repo "$repo" done < <(find "$RUN_ROOT" -type d -name .git -prune 2>/dev/null | sort) if [ "$found" -eq 0 ]; then echo "[run] no git run repos to sync in $RUN_ROOT" fi } cmd_task() { if [ "$#" -lt 2 ]; then echo "ERROR: task requires <name> <flow-task> [args...]" usage exit 1 fi local name="$1" shift validate_relative_path "$name" "name" local dir dir="$(repo_dir "$name")" run_task_in_dir "$dir" "$name" "$@" } cmd_ri() { # Shortcut: run task in $RUN_ROOT/i cmd_task i "$@" } cmd_r() { # Shortcut: run task in $RUN_ROOT (the public run repo itself) if [ "$#" -lt 1 ]; then echo "ERROR: r requires <flow-task> [args...]" exit 1 fi ensure_root run_task_in_dir "$RUN_ROOT" "root" "$@" } cmd_rp() { # Run a task in a run project by path/name (resolves internal fallback). if [ "$#" -lt 2 ]; then echo "ERROR: rp requires <project> <flow-task> [args...]" usage exit 1 fi local project="$1" shift local dir local label dir="$(resolve_project_dir "$project")" label="$(display_name_for_dir "$dir")" run_task_in_dir "$dir" "$label" "$@" } cmd_rip() { # Run a task in an internal run project: $RUN_ROOT/i/<project>. if [ "$#" -lt 2 ]; then echo "ERROR: rip requires <project> <flow-task> [args...]" usage exit 1 fi local project="$1" shift validate_relative_path "$project" "project" local dir dir="$(repo_dir "i/$project")" run_task_in_dir "$dir" "i/$project" "$@" } cmd_exec() { if [ "$#" -lt 3 ]; then echo "ERROR: exec requires <name> <repo-ssh-url> [--branch <branch>] <flow-task> [args...]" usage exit 1 fi local name="$1" validate_relative_path "$name" "name" local repo_url="$2" shift 2 local branch="" if [ "${1:-}" = "--branch" ]; then branch="${2:-}" if [ -z "$branch" ]; then echo "ERROR: --branch requires a value" usage exit 1 fi shift 2 fi if [ "$#" -lt 1 ]; then echo "ERROR: exec requires a flow task after repo parameters" usage exit 1 fi local dir dir="$(repo_dir "$name")" if [ -d "$dir" ] && [ -f "$dir/flow.toml" ] && ! is_git_repo "$dir"; then if is_git_repo "$RUN_ROOT"; then echo "[run] syncing monorepo root: $RUN_ROOT" if ! sync_git_repo "$RUN_ROOT"; then echo "[run] WARN: failed to sync monorepo root; using local checkout" fi fi echo "[run] using existing run task directory (non-git): $dir" else if [ -n "$branch" ]; then cmd_load "$name" "$repo_url" "$branch" else cmd_load "$name" "$repo_url" fi fi cmd_task "$name" "$@" } main() { local cmd="${1:-help}" shift || true case "$cmd" in root) cmd_root "$@" ;; ensure) cmd_ensure "$@" ;; list) cmd_list "$@" ;; load) cmd_load "$@" ;; sync) cmd_sync "$@" ;; task) cmd_task "$@" ;; ri) cmd_ri "$@" ;; r) cmd_r "$@" ;; rp) cmd_rp "$@" ;; rip) cmd_rip "$@" ;; exec) cmd_exec "$@" ;; help|-h|--help) usage ;; *) echo "ERROR: unknown command: $cmd" usage exit 1 ;; esac } main "$@" ================================================ FILE: scripts/setup-github-ssh.sh ================================================ #!/usr/bin/env bash set -euo pipefail # macOS helper to provision an SSH key for GitHub and configure ssh-agent. # Designed to be run non-interactively; set FLOW_SSH_PASSPHRASE if desired. fail() { echo "flow github ssh: $*" >&2 exit 1 } info() { echo "flow github ssh: $*" } if [[ "$(uname -s)" != "Darwin" ]]; then fail "this script is macOS-only" fi KEY_PATH="${FLOW_SSH_KEY_PATH:-$HOME/.ssh/id_ed25519}" EMAIL="${FLOW_SSH_EMAIL:-${USER}@$(hostname -s)}" PASSPHRASE="${FLOW_SSH_PASSPHRASE:-}" OPEN_GITHUB="${FLOW_OPEN_GITHUB:-1}" ensure_key() { if [[ -f "${KEY_PATH}" && -f "${KEY_PATH}.pub" ]]; then info "existing SSH key found at ${KEY_PATH}" return 0 fi info "generating SSH key at ${KEY_PATH}..." mkdir -p "$(dirname "${KEY_PATH}")" ssh-keygen -t ed25519 -C "${EMAIL}" -f "${KEY_PATH}" -N "${PASSPHRASE}" } ensure_agent() { if [[ -z "${SSH_AUTH_SOCK:-}" ]]; then eval "$(ssh-agent -s)" fi if ssh-add --apple-use-keychain "${KEY_PATH}" >/dev/null 2>&1; then return 0 fi ssh-add "${KEY_PATH}" } ensure_config() { local config_file="$HOME/.ssh/config" mkdir -p "$(dirname "${config_file}")" touch "${config_file}" if ! grep -q "Host github.com" "${config_file}"; then cat >> "${config_file}" <<EOF Host github.com AddKeysToAgent yes UseKeychain yes IdentityFile ${KEY_PATH} EOF info "updated ${config_file}" fi } print_next_steps() { info "" info "add this public key to GitHub:" info "1) Open https://github.com/settings/keys" info "2) Click \"New SSH key\"" info "3) Title: something like \"$(hostname -s)\"" info "4) Key type: Authentication" info "5) Key: paste the EXACT line below (starts with ssh-ed25519)" if command -v pbcopy >/dev/null 2>&1; then pbcopy < "${KEY_PATH}.pub" info "public key copied to clipboard" fi cat "${KEY_PATH}.pub" info "" if [[ "${OPEN_GITHUB}" != "0" ]] && command -v open >/dev/null 2>&1; then open "https://github.com/settings/keys" || true fi info "then run: ssh -T git@github.com" info "if it still says Permission denied, you may not have access to the private repo yet." } ensure_key ensure_agent ensure_config print_next_steps ================================================ FILE: scripts/setup-release-host.sh ================================================ #!/usr/bin/env bash set -euo pipefail if [[ $# -lt 2 ]]; then echo "Usage: $0 <ssh-host> <domain>" >&2 echo "Env: RELEASE_ROOT=/var/www/flow CADDYFILE_PATH=/etc/caddy/Caddyfile" >&2 exit 1 fi SSH_HOST="$1" DOMAIN="$2" RELEASE_ROOT="${RELEASE_ROOT:-/var/www/flow}" CADDYFILE_PATH="${CADDYFILE_PATH:-/etc/caddy/Caddyfile}" if ! command -v ssh >/dev/null 2>&1; then echo "ssh is required to configure the release host." >&2 exit 1 fi ssh "${SSH_HOST}" "sudo bash -s -- '${DOMAIN}' '${RELEASE_ROOT}' '${CADDYFILE_PATH}'" <<'EOF' set -euo pipefail DOMAIN="${1:?missing domain}" RELEASE_ROOT="${2:-/var/www/flow}" CADDYFILE_PATH="${3:-/etc/caddy/Caddyfile}" fail() { echo "release-host: $*" >&2 exit 1 } install_caddy() { if command -v caddy >/dev/null 2>&1; then return fi if ! command -v apt-get >/dev/null 2>&1; then fail "caddy not installed and apt-get not available" fi apt-get update apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null apt-get update apt-get install -y caddy } if [[ ! -d /run/systemd/system ]]; then fail "systemd is required to manage Caddy" fi install_caddy mkdir -p "${RELEASE_ROOT}" chmod 755 "${RELEASE_ROOT}" mkdir -p "$(dirname "${CADDYFILE_PATH}")" if [[ ! -f "${CADDYFILE_PATH}" ]]; then touch "${CADDYFILE_PATH}" fi if ! grep -Fq "${DOMAIN}" "${CADDYFILE_PATH}"; then cat <<CFG >> "${CADDYFILE_PATH}" ${DOMAIN} { root * ${RELEASE_ROOT} file_server } CFG fi systemctl enable --now caddy systemctl reload caddy echo "release-host: serving ${RELEASE_ROOT} on https://${DOMAIN}" EOF ================================================ FILE: scripts/sync-cdn.sh ================================================ #!/bin/bash set -e # Sync Flow releases to CDN server # Usage: ./scripts/sync-cdn.sh [version] # If no version specified, syncs latest release REPO="nikitavoloboev/flow" CDN_HOST="root@100.114.156.47" CDN_PATH="/var/www/cdn.myflow.sh" # Get version if [ -n "${1:-}" ]; then VERSION="$1" else echo "Fetching latest version..." VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') fi if [ -z "$VERSION" ]; then echo "Error: Could not determine version" exit 1 fi echo "Syncing version: $VERSION" TARGETS=( "x86_64-apple-darwin" "aarch64-apple-darwin" "x86_64-unknown-linux-gnu" "aarch64-unknown-linux-gnu" ) # Create temp directory TMP_DIR=$(mktemp -d) trap "rm -rf $TMP_DIR" EXIT # Download all artifacts echo "Downloading artifacts..." for target in "${TARGETS[@]}"; do url="https://github.com/${REPO}/releases/download/${VERSION}/flow-${target}.tar.gz" echo " Downloading flow-${target}.tar.gz..." curl -fsSL -o "$TMP_DIR/flow-${target}.tar.gz" "$url" || echo " Warning: failed to download $target" done # Download checksums echo " Downloading checksums.txt..." curl -fsSL -o "$TMP_DIR/checksums.txt" "https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" || true # Create version directory on CDN echo "Creating directory on CDN..." ssh "$CDN_HOST" "mkdir -p ${CDN_PATH}/${VERSION}" # Upload files echo "Uploading to CDN..." scp "$TMP_DIR"/* "${CDN_HOST}:${CDN_PATH}/${VERSION}/" # Update 'latest' symlink echo "Updating latest symlink..." ssh "$CDN_HOST" "cd ${CDN_PATH} && rm -f latest && ln -s ${VERSION} latest" echo "" echo "Done! Files available at:" echo " https://cdn.myflow.sh/${VERSION}/" echo " https://cdn.myflow.sh/latest/" ================================================ FILE: scripts/vendor/apply-trims.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" usage() { cat <<'USAGE' Usage: scripts/vendor/apply-trims.sh [crate] Examples: scripts/vendor/apply-trims.sh scripts/vendor/apply-trims.sh regex Behavior: - By default, this script is a no-op (safe baseline). - If scripts/vendor/trim-hooks.sh exists, it is sourced and called for extensible project-specific trims. USAGE } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi target_crate="${1:-}" if [[ -f scripts/vendor/trim-hooks.sh ]]; then # shellcheck source=/dev/null source scripts/vendor/trim-hooks.sh if declare -F apply_vendor_trims >/dev/null 2>&1; then apply_vendor_trims "${target_crate:-}" exit 0 fi fi if [[ -n "$target_crate" ]]; then echo "note: no default trim rules for '$target_crate' (create scripts/vendor/trim-hooks.sh)" else echo "note: no default trim rules (create scripts/vendor/trim-hooks.sh)" fi ================================================ FILE: scripts/vendor/bench_iteration.py ================================================ #!/usr/bin/env python3 """Record and compare compile-iteration timings for vendoring work.""" from __future__ import annotations import argparse import json import subprocess import time from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Literal Mode = Literal["incremental", "clean"] @dataclass class BenchRow: timestamp_utc: str project: str git_commit: str mode: Mode sample: int seconds: float command: str def utc_now() -> str: return datetime.now(tz=timezone.utc).isoformat() def git_head(project: Path) -> str: try: out = subprocess.check_output( ["git", "-C", str(project), "rev-parse", "--short", "HEAD"], text=True, ).strip() return out or "unknown" except Exception: return "unknown" def read_jsonl(path: Path) -> list[dict]: if not path.exists(): return [] rows: list[dict] = [] for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: rows.append(json.loads(line)) except json.JSONDecodeError: continue return rows def append_jsonl(path: Path, rows: list[BenchRow]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8") as fh: for row in rows: fh.write(json.dumps(asdict(row), ensure_ascii=False) + "\n") def run_cmd(project: Path, cmd: str) -> float: start = time.perf_counter() proc = subprocess.run(cmd, shell=True, cwd=project) end = time.perf_counter() if proc.returncode != 0: raise RuntimeError(f"command failed ({proc.returncode}): {cmd}") return end - start def run_sample(project: Path, mode: Mode, cmd: str) -> float: if mode == "clean": run_cmd(project, "cargo clean") return run_cmd(project, cmd) def summarize(values: list[float]) -> dict[str, float]: if not values: return {"min": 0.0, "avg": 0.0, "max": 0.0} return { "min": min(values), "avg": sum(values) / len(values), "max": max(values), } def main() -> None: parser = argparse.ArgumentParser(description="Benchmark vendoring iteration speed") parser.add_argument("--project", default=".", help="Project root (default: .)") parser.add_argument("--mode", choices=["incremental", "clean", "both"], default="incremental") parser.add_argument("--samples", type=int, default=3, help="Samples per mode") parser.add_argument("--cmd", default="cargo check -q", help="Command to benchmark") parser.add_argument("--record", default="out/vendor/iteration_bench.jsonl", help="JSONL output path") parser.add_argument("--compare-window", type=int, default=10, help="Prior rows to compare against") parser.add_argument("--fail-above", type=float, default=0.0, help="Fail if avg seconds exceeds threshold") args = parser.parse_args() project = Path(args.project).expanduser().resolve() record_path = project / args.record modes: list[Mode] if args.mode == "both": modes = ["clean", "incremental"] else: modes = [args.mode] prior = read_jsonl(record_path) git_commit = git_head(project) all_rows: list[BenchRow] = [] for mode in modes: print(f"mode: {mode}") values: list[float] = [] for i in range(1, args.samples + 1): secs = run_sample(project, mode, args.cmd) values.append(secs) row = BenchRow( timestamp_utc=utc_now(), project=str(project), git_commit=git_commit, mode=mode, sample=i, seconds=secs, command=args.cmd, ) all_rows.append(row) print(f" sample {i}/{args.samples}: {secs:.3f}s") stats = summarize(values) print(f" min/avg/max: {stats['min']:.3f}s / {stats['avg']:.3f}s / {stats['max']:.3f}s") prev_values = [ float(row.get("seconds", 0.0)) for row in prior if row.get("mode") == mode and row.get("command") == args.cmd ] if prev_values: window = prev_values[-args.compare_window :] prev_avg = sum(window) / len(window) delta = stats["avg"] - prev_avg direction = "+" if delta >= 0 else "-" print(f" delta vs last {len(window)} avg: {direction}{abs(delta):.3f}s (prev {prev_avg:.3f}s)") if args.fail_above > 0 and stats["avg"] > args.fail_above: append_jsonl(record_path, all_rows) raise SystemExit( f"avg {mode} time {stats['avg']:.3f}s exceeds fail-above {args.fail_above:.3f}s" ) append_jsonl(record_path, all_rows) print(f"recorded: {len(all_rows)} samples -> {record_path}") if __name__ == "__main__": main() ================================================ FILE: scripts/vendor/check-upstream.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" if ! command -v jq >/dev/null 2>&1; then echo "error: jq is required" exit 1 fi usage() { cat <<'USAGE' Usage: scripts/vendor/check-upstream.sh [--important] [--json] Options: --important Check only crates listed in scripts/vendor/important-crates.txt --json Emit machine-readable JSON array USAGE } important_only=false json_output=false for arg in "$@"; do case "$arg" in --important) important_only=true ;; --json) json_output=true ;; -h|--help) usage; exit 0 ;; *) usage; exit 1 ;; esac done important_file="scripts/vendor/important-crates.txt" if [[ "$important_only" == true && ! -f "$important_file" ]]; then echo "error: missing $important_file" exit 1 fi is_important() { local crate="$1" [[ -f "$important_file" ]] || return 1 rg -n "^${crate}$" "$important_file" >/dev/null 2>&1 } read_field() { local file="$1" local key="$2" awk -F'"' -v key="$key" '$1 ~ "^" key " = " { print $2; exit }' "$file" } classify_update_level() { local current="$1" local latest="$2" IFS='.' read -r c1 c2 c3 _ <<<"$current" IFS='.' read -r l1 l2 l3 _ <<<"$latest" if [[ -z "${c1:-}" || -z "${l1:-}" ]]; then echo "unknown" return fi if [[ "$current" == "$latest" ]]; then echo "same" elif [[ "$c1" != "$l1" ]]; then echo "major" elif [[ "${c2:-0}" != "${l2:-0}" ]]; then echo "minor" else echo "patch" fi } collect_metadata_files() { local files=() shopt -s nullglob for f in lib/vendor-manifest/*.toml; do files+=("$f") done # Backward compatibility while migrating from libs/vendor. if [[ ${#files[@]} -eq 0 ]]; then for f in lib/vendor/*/UPSTREAM.toml libs/vendor/*/UPSTREAM.toml; do files+=("$f") done fi shopt -u nullglob printf '%s\n' "${files[@]}" } rows=() while IFS= read -r meta_file; do [[ -f "$meta_file" ]] || continue crate="$(read_field "$meta_file" "crate")" current="$(read_field "$meta_file" "version")" [[ -n "$crate" && -n "$current" ]] || continue if [[ "$important_only" == true ]] && ! is_important "$crate"; then continue fi latest="$( curl -fsSL "https://crates.io/api/v1/crates/${crate}" \ | jq -r '.crate.max_stable_version // .crate.newest_version' )" level="$(classify_update_level "$current" "$latest")" status="up-to-date" if [[ "$latest" != "$current" ]]; then status="update-available" fi rows+=("${crate}|${current}|${latest}|${level}|${status}") done < <(collect_metadata_files) if [[ ${#rows[@]} -gt 0 ]]; then IFS=$'\n' sorted=($(printf '%s\n' "${rows[@]}" | sort)) unset IFS else sorted=() fi if [[ "$json_output" == true ]]; then if [[ ${#sorted[@]} -eq 0 ]]; then echo "[]" exit 0 fi for row in "${sorted[@]}"; do IFS='|' read -r crate current latest level status <<<"$row" printf '{"crate":"%s","current":"%s","latest":"%s","level":"%s","status":"%s"}\n' \ "$crate" "$current" "$latest" "$level" "$status" done | jq -s '.' else echo "crate current latest level status" for row in "${sorted[@]}"; do IFS='|' read -r crate current latest level status <<<"$row" printf "%s %s %s %s %s\n" "$crate" "$current" "$latest" "$level" "$status" done fi ================================================ FILE: scripts/vendor/important-crates.txt ================================================ reqwest axum tower-http ratatui url crypto_secretbox portable-pty tokio-stream tracing-subscriber futures sha1 sha2 tokio crossterm hmac toml clap notify-debouncer-mini ignore x25519-dalek rusqlite rmp-serde ctrlc notify regex serde ================================================ FILE: scripts/vendor/inhouse-crate.sh ================================================ #!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' Usage: scripts/vendor/inhouse-crate.sh <crate> [version] Examples: scripts/vendor/inhouse-crate.sh reqwest scripts/vendor/inhouse-crate.sh reqwest 0.12.24 Behavior: - Pulls crate source from local Cargo registry cache. - Commits snapshot into per-crate git history at lib/vendor-history/<crate>.git. - Materializes working copy into lib/vendor/<crate> for Cargo path patches. - Writes lib/vendor-manifest/<crate>.toml metadata for sync tracking. USAGE } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi if [[ $# -lt 1 || $# -gt 2 ]]; then usage exit 1 fi crate="$1" version="${2:-}" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" vendor_root="lib/vendor" history_root="lib/vendor-history" manifest_root="lib/vendor-manifest" registry_index="https://github.com/rust-lang/crates.io-index" find_cached_src_dir() { find "$HOME/.cargo/registry/src" -maxdepth 2 -type d -name "${crate}-${version}" 2>/dev/null \ | head -n 1 } find_cached_crate_file() { find "$HOME/.cargo/registry/cache" -maxdepth 2 -type f -name "${crate}-${version}.crate" 2>/dev/null \ | head -n 1 } fetch_into_cache() { local fetch_tmp fetch_tmp="$(mktemp -d)" cat > "${fetch_tmp}/Cargo.toml" <<EOF [package] name = "vendor-fetch-${crate}" version = "0.0.0" edition = "2021" [dependencies] ${crate} = "= ${version}" EOF mkdir -p "${fetch_tmp}/src" printf '%s\n' 'fn main() {}' > "${fetch_tmp}/src/main.rs" cargo fetch --manifest-path "${fetch_tmp}/Cargo.toml" >/dev/null 2>&1 || true rm -rf "$fetch_tmp" } resolve_version_from_lock() { awk -v crate="$crate" ' BEGIN { name = ""; version = ""; source = "" } $0 == "[[package]]" { if (name == crate && source ~ /^registry\+/) { registry_versions[version] = 1 } if (name == crate && version != "") { any_versions[version] = 1 } name = "" version = "" source = "" next } /^name = "/ { name = $3 gsub(/"/, "", name) next } /^version = "/ { version = $3 gsub(/"/, "", version) next } /^source = "/ { source = $3 gsub(/"/, "", source) next } END { if (name == crate && source ~ /^registry\+/) { registry_versions[version] = 1 } if (name == crate && version != "") { any_versions[version] = 1 } for (v in registry_versions) print v if (length(registry_versions) == 0) { for (v in any_versions) print v } } ' Cargo.lock | sort -V | tail -n 1 } resolve_checksum_from_lock() { awk -v crate="$crate" -v version="$version" ' BEGIN { name = ""; ver = ""; checksum = ""; found = 0 } $0 == "[[package]]" { if (name == crate && ver == version && checksum != "") { print checksum found = 1 exit 0 } name = "" ver = "" checksum = "" next } /^name = "/ { name = $3 gsub(/"/, "", name) next } /^version = "/ { ver = $3 gsub(/"/, "", ver) next } /^checksum = "/ { checksum = $3 gsub(/"/, "", checksum) next } END { if (found == 0 && name == crate && ver == version && checksum != "") { print checksum } } ' Cargo.lock } resolve_checksum_from_crates_io() { if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then return 0 fi curl -fsSL "https://crates.io/api/v1/crates/${crate}/${version}" 2>/dev/null \ | jq -r '.version.checksum // empty' 2>/dev/null \ || true } extract_toml_string() { local file="$1" local key="$2" awk -F'"' -v key="$key" '$1 ~ "^" key " = " { print $2; exit }' "$file" } sha256_file() { local file="$1" if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$file" | awk '{print $1}' elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$file" | awk '{print $1}' else echo "" fi } if [[ -z "$version" ]]; then version="$(resolve_version_from_lock)" fi if [[ -z "$version" ]]; then echo "error: could not resolve registry version for crate '$crate'" echo "hint: pass an explicit version: scripts/vendor/inhouse-crate.sh $crate <version>" exit 1 fi src_dir="$(find_cached_src_dir || true)" if [[ -z "$src_dir" ]]; then fetch_into_cache src_dir="$(find_cached_src_dir || true)" if [[ -z "$src_dir" ]]; then echo "error: could not find ${crate}-${version} in cargo cache after auto-fetch" echo "hint: check network/cargo registry config, then retry" exit 1 fi fi crate_archive_file="$(find_cached_crate_file || true)" mkdir -p "$history_root" "$vendor_root" "$manifest_root" history_repo_rel="${history_root}/${crate}.git" history_repo_abs="${repo_root}/${history_repo_rel}" if [[ ! -d "$history_repo_abs" ]]; then git init --bare "$history_repo_abs" >/dev/null fi tmp_dir="$(mktemp -d)" cleanup() { rm -rf "$tmp_dir" } trap cleanup EXIT checkout_dir="${tmp_dir}/${crate}" git init "$checkout_dir" >/dev/null git -C "$checkout_dir" remote add origin "$history_repo_abs" if git -C "$checkout_dir" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then git -C "$checkout_dir" fetch -q origin main git -C "$checkout_dir" checkout -q -B main FETCH_HEAD else git -C "$checkout_dir" checkout -q -B main fi # Keep script usable on fresh machines without requiring global git identity. if ! git -C "$checkout_dir" config user.email >/dev/null; then git -C "$checkout_dir" config user.email "vendor-bot@localhost" fi if ! git -C "$checkout_dir" config user.name >/dev/null; then git -C "$checkout_dir" config user.name "vendor-bot" fi find "$checkout_dir" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + rsync -a \ --delete \ --exclude '.git' \ --exclude '.cargo-ok' \ --exclude '.cargo_vcs_info.json' \ --exclude 'target' \ "$src_dir"/ "$checkout_dir"/ synced_at_utc="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" git -C "$checkout_dir" add -A if git -C "$checkout_dir" diff --cached --quiet; then commit_state="no-op" else git -C "$checkout_dir" -c commit.gpgSign=false commit -m "sync(${crate}): crates.io ${version}" >/dev/null commit_state="committed" fi git -C "$checkout_dir" push -q -u origin main git -C "$checkout_dir" -c tag.gpgSign=false tag -f -a "v${version}" -m "sync(${crate}): crates.io ${version}" >/dev/null git -C "$checkout_dir" push -q -f origin "refs/tags/v${version}" history_head="$(git -C "$checkout_dir" rev-parse HEAD)" upstream_repository="$(extract_toml_string "$src_dir/Cargo.toml" "repository")" upstream_homepage="$(extract_toml_string "$src_dir/Cargo.toml" "homepage")" registry_checksum="$(resolve_checksum_from_lock)" if [[ -z "$registry_checksum" ]]; then registry_checksum="$(resolve_checksum_from_crates_io)" fi registry_checksum="$(printf '%s' "$registry_checksum" | head -n 1 | tr -d '\r\n')" archive_sha256="" if [[ -n "$crate_archive_file" ]]; then archive_sha256="$(sha256_file "$crate_archive_file")" fi archive_sha256="$(printf '%s' "$archive_sha256" | tr -d '\r\n')" checksum_match="unknown" if [[ -n "$registry_checksum" && -n "$archive_sha256" ]]; then if [[ "$registry_checksum" == "$archive_sha256" ]]; then checksum_match="yes" else checksum_match="no" fi fi dest_dir_rel="${vendor_root}/${crate}" dest_dir_abs="${repo_root}/${dest_dir_rel}" rm -rf "$dest_dir_abs" mkdir -p "$dest_dir_abs" rsync -a \ --delete \ --exclude '.git' \ "$checkout_dir"/ "$dest_dir_abs"/ manifest_file="${manifest_root}/${crate}.toml" cat > "$manifest_file" <<MANIFEST crate = "${crate}" version = "${version}" source = "crates.io" registry_index = "${registry_index}" cargo_registry_checksum = "${registry_checksum}" crate_archive_sha256 = "${archive_sha256}" checksum_match = "${checksum_match}" upstream_repository = "${upstream_repository}" upstream_homepage = "${upstream_homepage}" synced_at_utc = "${synced_at_utc}" history_repo = "${history_repo_rel}" history_head = "${history_head}" materialized_path = "${dest_dir_rel}" sync_cmd = "scripts/vendor/inhouse-crate.sh ${crate} ${version}" MANIFEST # Compatibility metadata in materialized copy for quick local inspection. cat > "${dest_dir_abs}/UPSTREAM.toml" <<UPSTREAM crate = "${crate}" version = "${version}" source = "crates.io" registry_index = "${registry_index}" cargo_registry_checksum = "${registry_checksum}" crate_archive_sha256 = "${archive_sha256}" checksum_match = "${checksum_match}" upstream_repository = "${upstream_repository}" upstream_homepage = "${upstream_homepage}" synced_at_utc = "${synced_at_utc}" history_repo = "${history_repo_rel}" history_head = "${history_head}" sync_cmd = "scripts/vendor/inhouse-crate.sh ${crate} ${version}" UPSTREAM echo "inhouse ${crate}@${version} -> ${dest_dir_rel} (history: ${history_repo_rel}, ${commit_state})" ================================================ FILE: scripts/vendor/materialize-all.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" usage() { cat <<'USAGE' Usage: scripts/vendor/materialize-all.sh [--important] [--from-cache] Options: --important Materialize only crates listed in scripts/vendor/important-crates.txt --from-cache Ignore vendor.lock.toml and materialize from local crate cache metadata USAGE } important_only=false from_cache=false for arg in "$@"; do case "$arg" in --important) important_only=true ;; --from-cache) from_cache=true ;; -h|--help) usage; exit 0 ;; *) usage; exit 1 ;; esac done if [[ "$from_cache" == false && -f vendor.lock.toml ]]; then scripts/vendor/vendor-repo.sh hydrate exit 0 fi important_file="scripts/vendor/important-crates.txt" is_important() { local crate="$1" [[ -f "$important_file" ]] || return 1 rg -n "^${crate}$" "$important_file" >/dev/null 2>&1 } read_field() { local file="$1" local key="$2" awk -F'"' -v key="$key" '$1 ~ "^" key " = " { print $2; exit }' "$file" } shopt -s nullglob manifest_files=(lib/vendor-manifest/*.toml) shopt -u nullglob if [[ ${#manifest_files[@]} -eq 0 ]]; then echo "no manifests found in lib/vendor-manifest" exit 0 fi for manifest in "${manifest_files[@]}"; do crate="$(read_field "$manifest" "crate")" version="$(read_field "$manifest" "version")" [[ -n "$crate" && -n "$version" ]] || continue if [[ "$important_only" == true ]] && ! is_important "$crate"; then continue fi scripts/vendor/inhouse-crate.sh "$crate" "$version" scripts/vendor/apply-trims.sh "$crate" echo "materialized ${crate}@${version}" done ================================================ FILE: scripts/vendor/offenders.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" if ! command -v jq >/dev/null 2>&1; then echo "error: jq is required" exit 1 fi tmp_meta="$(mktemp)" trap 'rm -f "$tmp_meta"' EXIT cargo metadata --format-version 1 >"$tmp_meta" echo "== Registry Footprint ==" echo -n "unique registry crates: " jq -r ' [.packages[] | select(.source != null and (.source | startswith("registry+"))) | .name] | unique | length ' "$tmp_meta" echo -n "proc-macro crates: " jq -r ' [ .packages[] | select(any(.targets[]?; any(.kind[]?; . == "proc-macro"))) | .name ] | unique | length ' "$tmp_meta" echo echo "== Direct Dependencies Ranked By Tree Size ==" deps="$( sed -n '/^\[dependencies\]/,/^\[/p' Cargo.toml \ | rg -o '^[A-Za-z0-9_.-]+' \ | sort -u )" while IFS= read -r dep; do [[ -z "$dep" ]] && continue if lines="$(cargo tree -p "$dep" --depth 20 2>/dev/null | wc -l | tr -d ' ')"; then printf "%5d %s\n" "$lines" "$dep" fi done <<<"$deps" | sort -nr echo echo "== Duplicate Versions (cargo tree -d) ==" cargo tree -d ================================================ FILE: scripts/vendor/optimize_loop.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" usage() { cat <<'USAGE' Usage: scripts/vendor/optimize_loop.sh [--strict] [--no-bench] [--samples N] [--cmd "<command>"] Examples: scripts/vendor/optimize_loop.sh scripts/vendor/optimize_loop.sh --strict --samples 2 scripts/vendor/optimize_loop.sh --no-bench USAGE } strict=false no_bench=false samples="${VENDOR_BENCH_SAMPLES:-2}" bench_cmd="${VENDOR_BENCH_CMD:-cargo check -q}" while [[ $# -gt 0 ]]; do case "$1" in --strict) strict=true; shift ;; --no-bench) no_bench=true; shift ;; --samples) samples="${2:-}"; shift 2 ;; --cmd) bench_cmd="${2:-}"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "error: unknown arg: $1" usage exit 1 ;; esac done mkdir -p out/vendor echo "== vendor rough-edge audit ==" if [[ "$strict" == true ]]; then python3 scripts/vendor/rough_edges_audit.py --strict-warnings | tee out/vendor/rough_edges_audit.txt else python3 scripts/vendor/rough_edges_audit.py | tee out/vendor/rough_edges_audit.txt fi echo echo "== offender scan ==" scripts/vendor/offenders.sh | tee out/vendor/offenders_latest.txt if [[ "$no_bench" == false ]]; then echo echo "== iteration benchmark ==" python3 scripts/vendor/bench_iteration.py --mode incremental --samples "$samples" --cmd "$bench_cmd" fi echo echo "wrote:" echo " out/vendor/rough_edges_audit.txt" echo " out/vendor/offenders_latest.txt" if [[ "$no_bench" == false ]]; then echo " out/vendor/iteration_bench.jsonl" fi ================================================ FILE: scripts/vendor/rough_edges_audit.py ================================================ #!/usr/bin/env python3 """Audit rough edges in Cargo-first vendoring setup. This script is intentionally strict about structural invariants and surfaces actionable warnings for optimization workflow gaps. """ from __future__ import annotations import argparse import json from dataclasses import asdict, dataclass from pathlib import Path from typing import Any try: import tomllib # Python 3.11+ except ModuleNotFoundError as exc: # pragma: no cover raise SystemExit("python 3.11+ is required (missing tomllib)") from exc @dataclass class Finding: severity: str # error | warn | info code: str message: str hint: str | None = None def load_toml(path: Path) -> dict[str, Any]: with path.open("rb") as fh: return tomllib.load(fh) def read_manifest_crate(path: Path) -> str: try: data = load_toml(path) except Exception: return path.stem crate = str(data.get("crate", path.stem)).strip() return crate or path.stem def list_lock_crates(vendor_lock: dict[str, Any]) -> list[dict[str, str]]: out: list[dict[str, str]] = [] for row in vendor_lock.get("crate", []): if not isinstance(row, dict): continue name = str(row.get("name", "")).strip() if not name: continue out.append( { "name": name, "manifest_path": str(row.get("manifest_path", f"lib/vendor-manifest/{name}.toml")).strip(), "materialized_path": str(row.get("materialized_path", f"lib/vendor/{name}")).strip(), "repo_path": str(row.get("repo_path", f"crates/{name}")).strip(), } ) return out def read_patch_paths(cargo_toml: dict[str, Any]) -> dict[str, str]: patch = cargo_toml.get("patch", {}) if not isinstance(patch, dict): return {} crates_io = patch.get("crates-io", {}) if not isinstance(crates_io, dict): return {} out: dict[str, str] = {} for name, value in crates_io.items(): if isinstance(value, dict): path = value.get("path") if isinstance(path, str): out[name] = path return out def latest_mtime(paths: list[Path]) -> float: latest = 0.0 for path in paths: if not path.exists(): continue latest = max(latest, path.stat().st_mtime) return latest def check_warning_hygiene(project: Path) -> list[Finding]: findings: list[Finding] = [] checks: list[tuple[str, str]] = [ ( "lib/vendor/crossterm/src/lib.rs", 'cfg(all(winapi, not(feature = "winapi")))', ), ( "lib/vendor/crossterm/src/lib.rs", 'cfg(all(crossterm_winapi, not(feature = "crossterm_winapi")))', ), ( "lib/vendor/crossterm/src/terminal/sys/unix.rs", "map(|file| (FileDesc::Owned(file.into())))", ), ( "lib/vendor/portable-pty/src/unix.rs", 'feature = "cargo-clippy"', ), ( "lib/vendor/x25519-dalek/src/lib.rs", 'cfg_attr(feature = "bench", feature(test))', ), ( "lib/vendor/ratatui/src/terminal/terminal.rs", "pub fn get_frame(&mut self) -> Frame {", ), ( "lib/vendor/ratatui/src/terminal/terminal.rs", "pub fn draw<F>(&mut self, render_callback: F) -> io::Result<CompletedFrame>", ), ( "lib/vendor/ratatui/src/terminal/terminal.rs", "pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<CompletedFrame>", ), ( "lib/vendor/ratatui/src/text/line.rs", "pub fn iter(&self) -> std::slice::Iter<Span<'a>> {", ), ( "lib/vendor/ratatui/src/text/line.rs", "pub fn iter_mut(&mut self) -> std::slice::IterMut<Span<'a>> {", ), ( "lib/vendor/ratatui/src/text/text.rs", "pub fn iter(&self) -> std::slice::Iter<Line<'a>> {", ), ( "lib/vendor/ratatui/src/text/text.rs", "pub fn iter_mut(&mut self) -> std::slice::IterMut<Line<'a>> {", ), ( "lib/vendor/ratatui/src/text/text.rs", "fn to_text(&self) -> Text {", ), ( "lib/vendor/ratatui/src/widgets/block.rs", ") -> impl DoubleEndedIterator<Item = &Line> {", ), ] for rel_path, needle in checks: path = project / rel_path if not path.is_file(): continue try: content = path.read_text(encoding="utf-8") except Exception: continue if needle in content: findings.append( Finding( "warn", "warning_hygiene_regression", f"{rel_path}: found stale warning pattern `{needle}`", "run scripts/vendor/apply-trims.sh (or hydrate) to re-apply warning hygiene patches", ) ) return findings def build_report(project: Path) -> tuple[dict[str, Any], list[Finding]]: findings: list[Finding] = [] metrics: dict[str, Any] = { "project": str(project), "vendored_crates": 0, "vendor_manifests": 0, "vendor_patch_entries": 0, "direct_dependencies": 0, "direct_non_vendored_dependencies": 0, "direct_non_vendored_list": [], "warning_hygiene_regressions": 0, } vendor_lock_path = project / "vendor.lock.toml" cargo_toml_path = project / "Cargo.toml" cargo_lock_path = project / "Cargo.lock" if not vendor_lock_path.is_file(): findings.append( Finding( "error", "missing_vendor_lock", f"missing {vendor_lock_path}", "run bootstrap/inhouse flow to create vendor.lock.toml", ) ) return metrics, findings if not cargo_toml_path.is_file(): findings.append( Finding( "error", "missing_cargo_toml", f"missing {cargo_toml_path}", ) ) return metrics, findings if not cargo_lock_path.is_file(): findings.append( Finding( "error", "missing_cargo_lock", f"missing {cargo_lock_path}", "run cargo check to generate Cargo.lock", ) ) return metrics, findings vendor_lock = load_toml(vendor_lock_path) cargo_toml = load_toml(cargo_toml_path) cargo_lock = load_toml(cargo_lock_path) lock_section = "vendor" if "vendor" in vendor_lock else "flow_vendor" if "flow_vendor" in vendor_lock else None if lock_section is None: findings.append( Finding( "error", "missing_vendor_section", "vendor.lock.toml has no [vendor] or [flow_vendor] section", ) ) else: missing_keys = [ key for key in ("repo", "branch", "checkout") if not str(vendor_lock.get(lock_section, {}).get(key, "")).strip() ] if missing_keys: findings.append( Finding( "error", "vendor_section_incomplete", f"{lock_section} missing keys: {', '.join(missing_keys)}", ) ) lock_crates = list_lock_crates(vendor_lock) lock_crate_names = {row["name"] for row in lock_crates} metrics["vendored_crates"] = len(lock_crates) patch_paths = read_patch_paths(cargo_toml) vendor_patch_paths = {name: path for name, path in patch_paths.items() if path.startswith("lib/vendor/")} metrics["vendor_patch_entries"] = len(vendor_patch_paths) manifest_dir = project / "lib/vendor-manifest" manifest_files = sorted(manifest_dir.glob("*.toml")) if manifest_dir.is_dir() else [] manifest_crates = {read_manifest_crate(path) for path in manifest_files} metrics["vendor_manifests"] = len(manifest_files) package_rows = cargo_lock.get("package", []) packages_by_name: dict[str, list[dict[str, Any]]] = {} if isinstance(package_rows, list): for row in package_rows: if not isinstance(row, dict): continue name = str(row.get("name", "")).strip() if not name: continue packages_by_name.setdefault(name, []).append(row) dep_table = cargo_toml.get("dependencies", {}) if isinstance(dep_table, dict): direct_deps = sorted(dep_table.keys()) else: direct_deps = [] metrics["direct_dependencies"] = len(direct_deps) non_vendored = sorted(dep for dep in direct_deps if dep not in lock_crate_names) metrics["direct_non_vendored_dependencies"] = len(non_vendored) metrics["direct_non_vendored_list"] = non_vendored seen_lock = set() for row in lock_crates: crate = row["name"] seen_lock.add(crate) materialized = project / row["materialized_path"] if not (materialized / "Cargo.toml").is_file(): findings.append( Finding( "error", "missing_materialized_crate", f"{crate}: missing {materialized}/Cargo.toml", "run scripts/vendor/vendor-repo.sh hydrate", ) ) # `manifest_path` in vendor.lock.toml points to the vendor-repo path # (`manifests/<crate>.toml`), while the local materialized manifest # lives in `lib/vendor-manifest/<crate>.toml`. expected_repo_manifest = f"manifests/{crate}.toml" if row["manifest_path"] and row["manifest_path"] != expected_repo_manifest: findings.append( Finding( "warn", "manifest_repo_path_unexpected", f"{crate}: manifest_path={row['manifest_path']} (expected {expected_repo_manifest})", ) ) manifest_path = project / "lib/vendor-manifest" / f"{crate}.toml" if not manifest_path.is_file(): findings.append( Finding( "error", "missing_vendor_manifest", f"{crate}: missing local manifest {manifest_path}", "re-run inhouse/sync for this crate", ) ) else: try: crate_manifest = load_toml(manifest_path) except Exception as exc: findings.append( Finding( "error", "broken_vendor_manifest", f"{crate}: failed to parse {manifest_path}: {exc}", ) ) crate_manifest = {} manifest_crate = str(crate_manifest.get("crate", crate)).strip() manifest_version = str(crate_manifest.get("version", "")).strip() manifest_materialized = str(crate_manifest.get("materialized_path", row["materialized_path"])).strip() if manifest_crate and manifest_crate != crate: findings.append( Finding( "error", "manifest_crate_mismatch", f"{crate}: manifest crate={manifest_crate}", ) ) if manifest_materialized and manifest_materialized != row["materialized_path"]: findings.append( Finding( "error", "manifest_materialized_path_mismatch", f"{crate}: manifest materialized_path={manifest_materialized}, lock={row['materialized_path']}", ) ) if not str(crate_manifest.get("history_head", "")).strip(): findings.append( Finding( "warn", "missing_history_head", f"{crate}: missing history_head in {manifest_path}", ) ) if not str(crate_manifest.get("upstream_repository", "")).strip(): findings.append( Finding( "warn", "missing_upstream_repository", f"{crate}: missing upstream_repository in {manifest_path}", ) ) pkg_rows = packages_by_name.get(crate, []) versions = sorted({str(p.get("version", "")).strip() for p in pkg_rows if str(p.get("version", "")).strip()}) if manifest_version and versions and len(versions) == 1 and manifest_version != versions[0]: findings.append( Finding( "error", "manifest_version_mismatch", f"{crate}: manifest version={manifest_version}, Cargo.lock version={versions[0]}", ) ) patch_path = vendor_patch_paths.get(crate, "") if not patch_path: findings.append( Finding( "error", "missing_patch_entry", f"{crate}: missing [patch.crates-io] path override", "add crate path override in Cargo.toml", ) ) elif patch_path != row["materialized_path"]: findings.append( Finding( "error", "patch_path_mismatch", f"{crate}: patch path={patch_path}, lock materialized_path={row['materialized_path']}", ) ) pkg_rows = packages_by_name.get(crate, []) if not pkg_rows: findings.append( Finding( "error", "missing_cargo_lock_entry", f"{crate}: not present in Cargo.lock", ) ) else: sources = [str(p.get("source", "")).strip() for p in pkg_rows] if any(src.startswith("registry+") for src in sources): findings.append( Finding( "error", "registry_source_for_vendored_crate", f"{crate}: still resolves via registry source in Cargo.lock", "run cargo update -p <crate> --precise <version> after patching", ) ) versions = sorted({str(p.get("version", "")).strip() for p in pkg_rows if str(p.get("version", "")).strip()}) if len(versions) > 1: findings.append( Finding( "error", "multiple_lock_versions_for_vendored_crate", f"{crate}: multiple versions in Cargo.lock ({', '.join(versions)})", ) ) for crate in sorted(manifest_crates - lock_crate_names): findings.append( Finding( "warn", "manifest_not_in_lock", f"{crate}: manifest exists but crate not in vendor.lock.toml", ) ) for crate, path in sorted(vendor_patch_paths.items()): if crate not in lock_crate_names: findings.append( Finding( "warn", "patch_not_in_lock", f"{crate}: patched to {path} but not listed in vendor.lock.toml", ) ) vendor_src_dir = project / "lib/vendor" if vendor_src_dir.is_dir(): vendored_dirs = {p.name for p in vendor_src_dir.iterdir() if p.is_dir()} for extra in sorted(vendored_dirs - lock_crate_names): findings.append( Finding( "warn", "vendored_dir_not_in_lock", f"lib/vendor/{extra} exists but crate is not in vendor.lock.toml", ) ) typesense_index = project / ".vendor/typesense/sources.json" if typesense_index.exists(): watched = [vendor_lock_path, cargo_lock_path, project / "scripts/vendor/typesense_code_index.py"] watched.extend(manifest_files) if typesense_index.stat().st_mtime < latest_mtime(watched): findings.append( Finding( "warn", "stale_code_index", "typesense sources index is older than vendoring inputs", "run f vendor-code-index", ) ) else: findings.append( Finding( "info", "missing_code_index", "no .vendor/typesense/sources.json found", "run f vendor-code-index if you use vendor code search", ) ) warning_hygiene_findings = check_warning_hygiene(project) metrics["warning_hygiene_regressions"] = len(warning_hygiene_findings) findings.extend(warning_hygiene_findings) return metrics, findings def print_text(metrics: dict[str, Any], findings: list[Finding]) -> None: print(f"project: {metrics['project']}") print(f"vendored crates: {metrics['vendored_crates']}") print(f"vendor manifests: {metrics['vendor_manifests']}") print(f"vendor patch entries: {metrics['vendor_patch_entries']}") print(f"direct deps: {metrics['direct_dependencies']}") print(f"direct deps not yet vendored: {metrics['direct_non_vendored_dependencies']}") print(f"warning hygiene regressions: {metrics['warning_hygiene_regressions']}") if metrics["direct_non_vendored_list"]: preview = ", ".join(metrics["direct_non_vendored_list"][:12]) suffix = " ..." if len(metrics["direct_non_vendored_list"]) > 12 else "" print(f"non-vendored preview: {preview}{suffix}") print() if not findings: print("no findings") return for item in findings: print(f"[{item.severity}] {item.code}: {item.message}") if item.hint: print(f" hint: {item.hint}") def main() -> None: parser = argparse.ArgumentParser(description="Audit rough edges in vendored dependency workflow") parser.add_argument("--project", default=".", help="Project root (default: .)") parser.add_argument("--json", action="store_true", help="Emit report as JSON") parser.add_argument( "--strict-warnings", action="store_true", help="Exit non-zero on warnings (default only errors fail)", ) args = parser.parse_args() project = Path(args.project).expanduser().resolve() metrics, findings = build_report(project) errors = sum(1 for f in findings if f.severity == "error") warnings = sum(1 for f in findings if f.severity == "warn") payload = { "metrics": metrics, "counts": {"errors": errors, "warnings": warnings, "total": len(findings)}, "findings": [asdict(item) for item in findings], } if args.json: print(json.dumps(payload, indent=2)) else: print_text(metrics, findings) print() print(f"errors: {errors}") print(f"warnings: {warnings}") if errors > 0 or (args.strict_warnings and warnings > 0): raise SystemExit(1) if __name__ == "__main__": main() ================================================ FILE: scripts/vendor/sync-all.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" usage() { cat <<'EOF' Usage: scripts/vendor/sync-all.sh [--important] [--dry-run] [--allow-minor] [--allow-major] [--no-vendor-import] EOF } important_only=false dry_run=false allow_minor=false allow_major=false import_vendor_repo=true for arg in "$@"; do case "$arg" in --important) important_only=true ;; --dry-run) dry_run=true ;; --allow-minor) allow_minor=true ;; --allow-major) allow_major=true ;; --no-vendor-import) import_vendor_repo=false ;; -h|--help) usage; exit 0 ;; *) usage; exit 1 ;; esac done important_file="scripts/vendor/important-crates.txt" is_important() { local crate="$1" [[ -f "$important_file" ]] || return 1 rg -n "^${crate}$" "$important_file" >/dev/null 2>&1 } synced_any=false while read -r crate current latest level status; do [[ "$status" == "update-available" ]] || continue if [[ "$important_only" == true ]] && ! is_important "$crate"; then continue fi case "$level" in patch) ;; minor) [[ "$allow_minor" == true || "$allow_major" == true ]] || { echo "skip ${crate} ${current} -> ${latest} (minor; pass --allow-minor)" continue } ;; major) [[ "$allow_major" == true ]] || { echo "skip ${crate} ${current} -> ${latest} (major; pass --allow-major)" continue } ;; *) echo "skip ${crate} ${current} -> ${latest} (unknown level)" continue ;; esac if [[ "$dry_run" == true ]]; then echo "would sync ${crate} ${current} -> ${latest}" else scripts/vendor/sync-crate.sh "$crate" "$latest" --no-vendor-import synced_any=true fi done < <(scripts/vendor/check-upstream.sh) if [[ "$dry_run" == false && "$synced_any" == true && "$import_vendor_repo" == true && -f vendor.lock.toml ]]; then scripts/vendor/vendor-repo.sh import-local fi ================================================ FILE: scripts/vendor/sync-crate.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" if ! command -v jq >/dev/null 2>&1; then echo "error: jq is required" exit 1 fi usage() { cat <<'EOF' Usage: scripts/vendor/sync-crate.sh <crate> [version] [--no-vendor-import] Examples: scripts/vendor/sync-crate.sh reqwest scripts/vendor/sync-crate.sh reqwest 0.12.24 EOF } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi import_vendor_repo=true args=() for arg in "$@"; do case "$arg" in --no-vendor-import) import_vendor_repo=false ;; *) args+=("$arg") ;; esac done if [[ ${#args[@]} -lt 1 || ${#args[@]} -gt 2 ]]; then usage exit 1 fi crate="${args[0]}" version="${args[1]:-}" if [[ -z "$version" ]]; then version="$( curl -fsSL "https://crates.io/api/v1/crates/${crate}" \ | jq -r '.crate.max_stable_version // .crate.newest_version' )" fi scripts/vendor/inhouse-crate.sh "$crate" "$version" scripts/vendor/apply-trims.sh "$crate" if [[ "$import_vendor_repo" == true && -f vendor.lock.toml ]]; then scripts/vendor/vendor-repo.sh import-local fi echo "synced ${crate}@${version} and re-applied local trims" ================================================ FILE: scripts/vendor/trim-hooks.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Project-specific trim rules for Flow vendored crates. # Called by scripts/vendor/apply-trims.sh as: apply_vendor_trims "<crate>" apply_reqwest_trims() { local file="lib/vendor/reqwest/Cargo.toml" [[ -f "$file" ]] || return 0 # Keep hyper surfaces as explicit as possible; avoid implicit default feature fan-out. perl -0777 -i -pe ' s/(\[target\.\x27cfg\(not\(target_arch = "wasm32"\)\)\x27\.dependencies\.hyper\]\nversion = "1\.1"\nfeatures = \[\n "http1",\n "client",\n\]\n)(?!default-features = false\n)/$1default-features = false\n/s; s/(\[target\.\x27cfg\(not\(target_arch = "wasm32"\)\)\x27\.dependencies\.hyper-util\]\nversion = "0\.1\.12"\nfeatures = \[\n "http1",\n "client",\n "client-legacy",\n "client-proxy",\n "tokio",\n\]\n)(?!default-features = false\n)/$1default-features = false\n/s; ' "$file" } apply_axum_trims() { local file="lib/vendor/axum/Cargo.toml" [[ -f "$file" ]] || return 0 perl -0777 -i -pe ' s/(\[dependencies\.hyper\]\nversion = "1\.1\.0"\noptional = true\n)(?!default-features = false\n)/$1default-features = false\n/s; s/(\[dependencies\.hyper-util\]\nversion = "0\.1\.3"\nfeatures = \[\n "tokio",\n "server",\n "service",\n\]\noptional = true\n)(?!default-features = false\n)/$1default-features = false\n/s; ' "$file" } apply_ratatui_trims() { local root="lib/vendor/ratatui" [[ -d "$root" ]] || return 0 rm -rf \ "$root/benches" \ "$root/examples" \ "$root/tests" rm -f \ "$root/Cargo.lock" \ "$root/.cz.toml" \ "$root/.editorconfig" \ "$root/.gitignore" \ "$root/.markdownlint.yaml" \ "$root/bacon.toml" \ "$root/cliff.toml" \ "$root/clippy.toml" \ "$root/codecov.yml" \ "$root/committed.toml" \ "$root/deny.toml" \ "$root/FUNDING.json" \ "$root/MAINTAINERS.md" \ "$root/RELEASE.md" \ "$root/SECURITY.md" \ "$root/BREAKING-CHANGES.md" # Rust 1.90+ warns on elided lifetime name mismatches in these signatures. local terminal_file="$root/src/terminal/terminal.rs" local text_line_file="$root/src/text/line.rs" local text_text_file="$root/src/text/text.rs" local widgets_block_file="$root/src/widgets/block.rs" if [[ -f "$terminal_file" ]]; then perl -0777 -i -pe ' s/pub fn get_frame\(&mut self\) -> Frame \{/pub fn get_frame(&mut self) -> Frame<'\''_> {/g; s/pub fn draw<F>\(&mut self, render_callback: F\) -> io::Result<CompletedFrame>/pub fn draw<F>(&mut self, render_callback: F) -> io::Result<CompletedFrame<'\''_>>/g; s/pub fn try_draw<F, E>\(&mut self, render_callback: F\) -> io::Result<CompletedFrame>/pub fn try_draw<F, E>(&mut self, render_callback: F) -> io::Result<CompletedFrame<'\''_>>/g; ' "$terminal_file" fi if [[ -f "$text_line_file" ]]; then perl -0777 -i -pe ' s/pub fn iter\(&self\) -> std::slice::Iter<Span<'\''a>>/pub fn iter(&self) -> std::slice::Iter<'\''_, Span<'\''a>>/g; s/pub fn iter_mut\(&mut self\) -> std::slice::IterMut<Span<'\''a>>/pub fn iter_mut(&mut self) -> std::slice::IterMut<'\''_, Span<'\''a>>/g; ' "$text_line_file" fi if [[ -f "$text_text_file" ]]; then perl -0777 -i -pe ' s/pub fn iter\(&self\) -> std::slice::Iter<Line<'\''a>>/pub fn iter(&self) -> std::slice::Iter<'\''_, Line<'\''a>>/g; s/pub fn iter_mut\(&mut self\) -> std::slice::IterMut<Line<'\''a>>/pub fn iter_mut(&mut self) -> std::slice::IterMut<'\''_, Line<'\''a>>/g; s/fn to_text\(&self\) -> Text \{/fn to_text(&self) -> Text<'\''_> {/g; ' "$text_text_file" fi if [[ -f "$widgets_block_file" ]]; then perl -0777 -i -pe ' s/\) -> impl DoubleEndedIterator<Item = &Line> \{/) -> impl DoubleEndedIterator<Item = &Line<'\''_>> {/g; ' "$widgets_block_file" fi } apply_crossterm_trims() { local root="lib/vendor/crossterm" [[ -d "$root" ]] || return 0 local lib_file="$root/src/lib.rs" local unix_file="$root/src/terminal/sys/unix.rs" local filter_file="$root/src/event/filter.rs" if [[ -f "$lib_file" ]]; then perl -0777 -i -pe ' s/\n#\[cfg\(all\(winapi, not\(feature = "winapi"\)\)\)\]\ncompile_error!\("Compiling on Windows with \\"winapi\\" feature disabled\. Feature \\"winapi\\" should only be disabled when project will never be compiled on Windows\."\);\n//g; s/\n#\[cfg\(all\(crossterm_winapi, not\(feature = "crossterm_winapi"\)\)\)\]\ncompile_error!\("Compiling on Windows with \\"crossterm_winapi\\" feature disabled\. Feature \\"crossterm_winapi\\" should only be disabled when project will never be compiled on Windows\."\);\n//g; ' "$lib_file" fi if [[ -f "$unix_file" ]]; then perl -0777 -i -pe ' s/File::open\("\/dev\/tty"\)\.map\(\|file\| \(FileDesc::Owned\(file\.into\(\)\)\)\)/File::open("\/dev\/tty").map(|file| FileDesc::Owned(file.into()))/g; ' "$unix_file" fi if [[ -f "$filter_file" ]]; then perl -0777 -i -pe ' if (!/\#\[allow\(dead_code\)\]\s*pub\(crate\) struct InternalEventFilter;/s) { s/\#\[derive\(Debug, Clone\)\]\s*pub\(crate\) struct InternalEventFilter;/#[derive(Debug, Clone)]\n#[allow(dead_code)]\npub(crate) struct InternalEventFilter;/s; } ' "$filter_file" fi } apply_portable_pty_trims() { local file="lib/vendor/portable-pty/src/unix.rs" if [[ -f "$file" ]]; then perl -0777 -i -pe ' s/\n[ \t]*#\[cfg_attr\(feature = "cargo-clippy", allow\(clippy::unnecessary_mut_passed\)\)\]//g; s/\n[ \t]*#\[cfg_attr\(feature = "cargo-clippy", allow\(clippy::cast_lossless\)\)\]//g; ' "$file" fi } apply_x25519_dalek_trims() { local file="lib/vendor/x25519-dalek/src/lib.rs" if [[ -f "$file" ]]; then perl -0777 -i -pe ' s/\n#!\[cfg_attr\(feature = "bench", feature\(test\)\)\]//g; ' "$file" fi } apply_vendor_trims() { local crate="${1:-}" if [[ -n "$crate" ]]; then case "$crate" in reqwest) apply_reqwest_trims ;; axum) apply_axum_trims ;; ratatui) apply_ratatui_trims ;; crossterm) apply_crossterm_trims ;; portable-pty) apply_portable_pty_trims ;; x25519-dalek) apply_x25519_dalek_trims ;; *) ;; esac return fi apply_reqwest_trims apply_axum_trims apply_ratatui_trims apply_crossterm_trims apply_portable_pty_trims apply_x25519_dalek_trims } ================================================ FILE: scripts/vendor/typesense_code_index.py ================================================ #!/usr/bin/env python3 """Index first-party + vendored code into Typesense for fast local search. Inspired by opensrc's "source inventory + local context" workflow, but targeted at Rust/Cargo vendoring. This script reads Flow vendoring metadata and builds: - <prefix>_sources: source inventory (vendored crates + first-party repo) - <prefix>_chunks: code chunks for full-text search """ from __future__ import annotations import argparse import hashlib import json import os import re from dataclasses import dataclass from pathlib import Path from typing import Iterable, NoReturn from urllib import error, parse, request try: import tomllib # Python 3.11+ except ModuleNotFoundError as exc: # pragma: no cover raise SystemExit("python 3.11+ is required (missing tomllib)") from exc def env_first(*names: str, default: str) -> str: for name in names: value = os.environ.get(name) if value: return value return default DEFAULT_TYPESENSE_URL = env_first("LINSA_TYPESENSE_URL", "TYPESENSE_URL", default="http://127.0.0.1:8108") DEFAULT_TYPESENSE_API_KEY = env_first("LINSA_TYPESENSE_API_KEY", "TYPESENSE_API_KEY", default="ts_local_dev_key") DEFAULT_PREFIX = "flow_code" DEFAULT_SOURCE_INDEX = ".vendor/typesense/sources.json" TEXT_EXTS = { ".rs", ".toml", ".md", ".txt", ".yaml", ".yml", ".json", ".sh", ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".cpp", ".cc", ".c", ".h", ".hpp", ".proto", } FIRST_PARTY_DIRS = ["src", "crates", "scripts", "docs", "tests"] EXCLUDE_DIRS = { ".git", ".jj", "target", "node_modules", ".vendor", "dist", "build", ".next", ".venv", "out", } RUST_SYMBOL_PATTERNS = [ re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)"), re.compile(r"^\s*(?:pub\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)"), re.compile(r"^\s*(?:pub\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)"), re.compile(r"^\s*(?:pub\s+)?trait\s+([A-Za-z_][A-Za-z0-9_]*)"), re.compile(r"^\s*impl\s+(?:<[^>]+>\s*)?([A-Za-z_][A-Za-z0-9_]*)"), ] GENERIC_SYMBOL_PATTERNS = [ re.compile(r"^\s*def\s+([A-Za-z_][A-Za-z0-9_]*)"), re.compile(r"^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)"), re.compile(r"^\s*function\s+([A-Za-z_][A-Za-z0-9_]*)"), ] @dataclass class SourceEntry: source_id: str kind: str scope: str name: str version: str | None materialized_path: str upstream_repository: str | None history_head: str | None checksum: str | None synced_at_utc: str | None def die(msg: str) -> NoReturn: raise SystemExit(msg) def load_toml(path: Path) -> dict: with path.open("rb") as fh: return tomllib.load(fh) def load_vendor_sources(project: Path) -> list[SourceEntry]: vendor_lock = load_vendor_lock(project) flow_vendor_meta = vendor_lock.get("flow_vendor", {}) lock_crates = _as_list(vendor_lock.get("crate")) vendor_commit = _s(_as_dict(flow_vendor_meta).get("commit")) by_crate: dict[str, SourceEntry] = {} for item in lock_crates: row = _as_dict(item) crate = _s(row.get("name")) if crate is None: continue by_crate[crate] = SourceEntry( source_id=f"vendor:{crate}", kind="crate", scope="vendor", name=crate, version=None, materialized_path=_s(row.get("materialized_path")) or f"lib/vendor/{crate}", upstream_repository=None, history_head=vendor_commit, checksum=None, synced_at_utc=None, ) manifest_dir = project / "lib/vendor-manifest" if manifest_dir.is_dir(): for manifest in sorted(manifest_dir.glob("*.toml")): data = load_toml(manifest) crate = str(data.get("crate", manifest.stem)) prev = by_crate.get(crate) by_crate[crate] = SourceEntry( source_id=f"vendor:{crate}", kind="crate", scope="vendor", name=crate, version=_s(data.get("version")), materialized_path=( _s(data.get("materialized_path")) or (prev.materialized_path if prev else None) or f"lib/vendor/{crate}" ), upstream_repository=_s(data.get("upstream_repository")) or (prev.upstream_repository if prev else None), history_head=_s(data.get("history_head")) or (prev.history_head if prev else None), checksum=_s(data.get("cargo_registry_checksum")), synced_at_utc=_s(data.get("synced_at_utc")), ) entries = sorted(by_crate.values(), key=lambda s: s.name) entries.append( SourceEntry( source_id="firstparty:flow", kind="repo", scope="firstparty", name=project.name, version=None, materialized_path=".", upstream_repository=None, history_head=None, checksum=None, synced_at_utc=None, ) ) return entries def load_vendor_lock(project: Path) -> dict: path = project / "vendor.lock.toml" if not path.is_file(): return {} return load_toml(path) def _as_dict(value: object) -> dict: return value if isinstance(value, dict) else {} def _as_list(value: object) -> list: return value if isinstance(value, list) else [] def _s(v: object) -> str | None: if v is None: return None s = str(v).strip() return s if s else None def typesense_request( method: str, url: str, api_key: str, *, payload: bytes | None = None, content_type: str = "application/json", ) -> tuple[int, bytes]: headers = {"X-TYPESENSE-API-KEY": api_key} if payload is not None: headers["Content-Type"] = content_type req = request.Request(url=url, method=method, data=payload, headers=headers) try: with request.urlopen(req, timeout=30) as resp: return resp.status, resp.read() except error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"Typesense {method} {url} failed ({exc.code}): {body}") from exc except error.URLError as exc: reason = getattr(exc, "reason", exc) raise RuntimeError(f"Typesense {method} {url} failed (connection): {reason}") from exc def collection_url(base: str, name: str) -> str: return f"{base.rstrip('/')}/collections/{parse.quote(name)}" def ensure_collection(base_url: str, api_key: str, name: str, fields: list[dict], dry_run: bool) -> None: if dry_run: return url = collection_url(base_url, name) try: status, _ = typesense_request("GET", url, api_key) if status == 200: return except RuntimeError as err: if "(404)" not in str(err): raise schema = {"name": name, "fields": fields} typesense_request( "POST", f"{base_url.rstrip('/')}/collections", api_key, payload=json.dumps(schema).encode("utf-8"), ) def import_jsonl(base_url: str, api_key: str, collection: str, docs: list[dict], dry_run: bool) -> int: if not docs: return 0 if dry_run: return len(docs) jsonl = "\n".join(json.dumps(d, ensure_ascii=False) for d in docs) + "\n" url = f"{collection_url(base_url, collection)}/documents/import?action=upsert" _, body = typesense_request( "POST", url, api_key, payload=jsonl.encode("utf-8"), content_type="text/plain", ) lines = [line for line in body.decode("utf-8", errors="replace").splitlines() if line.strip()] failed = 0 for line in lines: try: item = json.loads(line) except json.JSONDecodeError: continue if not item.get("success", False): failed += 1 if failed: raise RuntimeError(f"Typesense import reported {failed} failed docs in {collection}") return len(lines) def iter_text_files(root: Path, *, exclude_vendor: bool) -> Iterable[Path]: for path in root.rglob("*"): if not path.is_file(): continue if any(part in EXCLUDE_DIRS for part in path.parts): continue if exclude_vendor and "lib" in path.parts and "vendor" in path.parts: continue if path.suffix.lower() in TEXT_EXTS: yield path def extract_symbols(path: Path, lines: list[str]) -> list[str]: patterns = RUST_SYMBOL_PATTERNS if path.suffix == ".rs" else GENERIC_SYMBOL_PATTERNS symbols: list[str] = [] seen: set[str] = set() for line in lines: for pat in patterns: m = pat.search(line) if not m: continue sym = m.group(1) if sym in seen: continue seen.add(sym) symbols.append(sym) if len(symbols) >= 24: return symbols return symbols def chunk_lines(lines: list[str], chunk_size: int, overlap: int) -> Iterable[tuple[int, int, str]]: if not lines: return start = 0 count = len(lines) while start < count: end = min(start + chunk_size, count) text = "\n".join(lines[start:end]).strip() if text: yield start + 1, end, text if end >= count: break start = max(end - overlap, start + 1) def lang_for(path: Path) -> str: ext = path.suffix.lower().lstrip(".") return ext or "text" def file_to_chunks( project: Path, file_path: Path, *, source: SourceEntry, chunk_lines_n: int, overlap: int, ) -> list[dict]: rel = file_path.relative_to(project).as_posix() raw = file_path.read_text(encoding="utf-8", errors="replace") lines = raw.splitlines() symbols = extract_symbols(file_path, lines) docs: list[dict] = [] for line_start, line_end, content in chunk_lines(lines, chunk_lines_n, overlap): key = f"{source.source_id}|{rel}|{line_start}|{line_end}" doc_id = hashlib.sha1(key.encode("utf-8")).hexdigest() docs.append( { "id": doc_id, "kind": "code", "project": project.name, "scope": source.scope, "source_id": source.source_id, "crate": source.name if source.scope == "vendor" else "", "rel_path": rel, "lang": lang_for(file_path), "symbols": symbols, "line_start": line_start, "line_end": line_end, "preview": content[:220], "content": content, } ) return docs def build_sources_docs(project: Path, sources: list[SourceEntry]) -> list[dict]: docs = [] for src in sources: docs.append( { "id": src.source_id, "project": project.name, "kind": src.kind, "scope": src.scope, "name": src.name, "version": src.version or "", "materialized_path": src.materialized_path, "upstream_repository": src.upstream_repository or "", "history_head": src.history_head or "", "checksum": src.checksum or "", "synced_at_utc": src.synced_at_utc or "", } ) return docs def collect_chunk_docs( project: Path, sources: list[SourceEntry], *, chunk_lines_n: int, overlap: int, max_files: int, ) -> list[dict]: docs: list[dict] = [] seen_files = 0 # vendored sources for src in sources: if src.scope != "vendor": continue root = project / src.materialized_path if not root.is_dir(): continue for file_path in iter_text_files(root, exclude_vendor=False): docs.extend( file_to_chunks( project, file_path, source=src, chunk_lines_n=chunk_lines_n, overlap=overlap, ) ) seen_files += 1 if max_files and seen_files >= max_files: return docs # first-party sources first = next((s for s in sources if s.scope == "firstparty"), None) if first is None: return docs for directory in FIRST_PARTY_DIRS: root = project / directory if not root.is_dir(): continue for file_path in iter_text_files(root, exclude_vendor=True): docs.extend( file_to_chunks( project, file_path, source=first, chunk_lines_n=chunk_lines_n, overlap=overlap, ) ) seen_files += 1 if max_files and seen_files >= max_files: return docs return docs def write_sources_index(project: Path, sources: list[SourceEntry], out_path: str) -> Path: path = project / out_path path.parent.mkdir(parents=True, exist_ok=True) payload = { "project": project.name, "updated_at": _utc_now(), "sources": [ { "source_id": s.source_id, "kind": s.kind, "scope": s.scope, "name": s.name, "version": s.version, "materialized_path": s.materialized_path, "upstream_repository": s.upstream_repository, "history_head": s.history_head, "checksum": s.checksum, "synced_at_utc": s.synced_at_utc, } for s in sources ], } path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") return path def _utc_now() -> str: from datetime import datetime, timezone return datetime.now(tz=timezone.utc).isoformat() def cmd_index(args: argparse.Namespace) -> None: project = Path(args.project).expanduser().resolve() if not (project / "Cargo.toml").is_file(): die(f"not a cargo project: {project}") sources = load_vendor_sources(project) source_index = project / args.sources_index if not args.dry_run: source_index = write_sources_index(project, sources, args.sources_index) sources_collection = f"{args.prefix}_sources" chunks_collection = f"{args.prefix}_chunks" source_fields = [ {"name": "id", "type": "string"}, {"name": "project", "type": "string", "facet": True}, {"name": "kind", "type": "string", "facet": True}, {"name": "scope", "type": "string", "facet": True}, {"name": "name", "type": "string", "facet": True}, {"name": "version", "type": "string", "facet": True, "optional": True}, {"name": "materialized_path", "type": "string", "optional": True}, {"name": "upstream_repository", "type": "string", "optional": True}, {"name": "history_head", "type": "string", "optional": True}, {"name": "checksum", "type": "string", "optional": True}, {"name": "synced_at_utc", "type": "string", "optional": True}, ] chunk_fields = [ {"name": "id", "type": "string"}, {"name": "kind", "type": "string", "facet": True}, {"name": "project", "type": "string", "facet": True}, {"name": "scope", "type": "string", "facet": True}, {"name": "source_id", "type": "string", "facet": True}, {"name": "crate", "type": "string", "facet": True, "optional": True}, {"name": "rel_path", "type": "string"}, {"name": "lang", "type": "string", "facet": True}, {"name": "symbols", "type": "string[]", "optional": True}, {"name": "line_start", "type": "int32", "optional": True}, {"name": "line_end", "type": "int32", "optional": True}, {"name": "preview", "type": "string", "optional": True}, {"name": "content", "type": "string"}, ] ensure_collection(args.url, args.api_key, sources_collection, source_fields, args.dry_run) ensure_collection(args.url, args.api_key, chunks_collection, chunk_fields, args.dry_run) source_docs = build_sources_docs(project, sources) indexed_sources = import_jsonl(args.url, args.api_key, sources_collection, source_docs, args.dry_run) chunk_docs = collect_chunk_docs( project, sources, chunk_lines_n=args.chunk_lines, overlap=args.chunk_overlap, max_files=args.max_files, ) indexed_chunks = 0 for i in range(0, len(chunk_docs), args.batch_size): batch = chunk_docs[i : i + args.batch_size] indexed_chunks += import_jsonl(args.url, args.api_key, chunks_collection, batch, args.dry_run) print(f"project: {project}") print(f"sources index: {source_index}") print(f"typesense url: {args.url}") print(f"sources docs: {indexed_sources}") print(f"chunk docs: {indexed_chunks}") print(f"sources coll: {sources_collection}") print(f"chunks coll: {chunks_collection}") if args.dry_run: print("mode: dry-run (no writes to Typesense)") def _build_filter(args: argparse.Namespace) -> str | None: filters: list[str] = [] if args.scope: filters.append(f"scope:={args.scope}") if args.crate: field = "name" if args.collection == "sources" else "crate" filters.append(f"{field}:={args.crate}") if args.lang and args.collection != "sources": filters.append(f"lang:={args.lang}") if args.path_prefix: field = "materialized_path" if args.collection == "sources" else "rel_path" filters.append(f"{field}:{args.path_prefix}") return " && ".join(filters) if filters else None def cmd_search(args: argparse.Namespace) -> None: collection = f"{args.prefix}_{args.collection}" if args.collection == "sources": query_by = "name,materialized_path,upstream_repository,version,checksum,history_head" highlight_fields = "name,materialized_path,upstream_repository" else: query_by = "content,rel_path,crate,symbols,preview" highlight_fields = "content,preview" params = { "q": args.query, "query_by": query_by, "per_page": str(args.limit), "highlight_fields": highlight_fields, } filter_by = _build_filter(args) if filter_by: params["filter_by"] = filter_by url = f"{collection_url(args.url, collection)}/documents/search?{parse.urlencode(params)}" _, body = typesense_request("GET", url, args.api_key) data = json.loads(body.decode("utf-8")) if args.json: print(json.dumps(data, indent=2)) return found = data.get("found", 0) hits = data.get("hits", []) print(f"collection: {collection}") print(f"found: {found}") print() for idx, hit in enumerate(hits, start=1): doc = hit.get("document", {}) if args.collection == "sources": scope = doc.get("scope", "") name = doc.get("name", "") version = doc.get("version", "") materialized_path = doc.get("materialized_path", "") upstream = doc.get("upstream_repository", "") checksum = doc.get("checksum", "") synced_at = doc.get("synced_at_utc", "") header = f"{idx:02d}. {scope}::{name}" if version: header += f" v{version}" print(header) print(f" path: {materialized_path}") if upstream: print(f" upstream: {upstream}") if checksum: print(f" checksum: {checksum}") if synced_at: print(f" synced_at_utc: {synced_at}") continue rel_path = doc.get("rel_path") or doc.get("materialized_path") or "" scope = doc.get("scope", "") crate = doc.get("crate", "") line_start = doc.get("line_start") line_end = doc.get("line_end") line_part = "" if line_start and line_end: line_part = f" [{line_start}-{line_end}]" header = f"{idx:02d}. {scope}" if crate: header += f"::{crate}" header += f" {rel_path}{line_part}" print(header) snippet = doc.get("preview") or doc.get("content") or "" snippet = str(snippet).replace("\n", " ").strip() if len(snippet) > 220: snippet = snippet[:220] + "..." print(f" {snippet}") def cmd_sources(args: argparse.Namespace) -> None: project = Path(args.project).expanduser().resolve() sources = load_vendor_sources(project) out = { "project": project.name, "updated_at": _utc_now(), "sources": [ { "source_id": s.source_id, "kind": s.kind, "scope": s.scope, "name": s.name, "version": s.version, "materialized_path": s.materialized_path, "upstream_repository": s.upstream_repository, "history_head": s.history_head, "checksum": s.checksum, "synced_at_utc": s.synced_at_utc, } for s in sources ], } if args.write: path = write_sources_index(project, sources, args.sources_index) print(path) else: print(json.dumps(out, indent=2)) def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description="Typesense code index/search for Flow vendored + first-party code") p.add_argument("--project", default=".", help="Project root (default: current directory)") p.add_argument("--url", default=DEFAULT_TYPESENSE_URL, help="Typesense URL") p.add_argument("--api-key", default=DEFAULT_TYPESENSE_API_KEY, help="Typesense API key") p.add_argument("--prefix", default=DEFAULT_PREFIX, help="Collection prefix") p.add_argument( "--sources-index", default=DEFAULT_SOURCE_INDEX, help="Path (relative to project) for generated sources index JSON", ) sub = p.add_subparsers(dest="command", required=True) p_index = sub.add_parser("index", help="Index first-party + vendored code") p_index.add_argument("--chunk-lines", type=int, default=120, help="Lines per chunk") p_index.add_argument("--chunk-overlap", type=int, default=20, help="Overlapped lines between chunks") p_index.add_argument("--batch-size", type=int, default=250, help="Import batch size") p_index.add_argument("--max-files", type=int, default=0, help="Debug limit (0 = no limit)") p_index.add_argument("--dry-run", action="store_true", help="Do not write to Typesense") p_index.set_defaults(func=cmd_index) p_search = sub.add_parser("search", help="Search indexed code/sources") p_search.add_argument("query", help="Search query") p_search.add_argument("--collection", choices=["chunks", "sources"], default="chunks") p_search.add_argument("--scope", choices=["vendor", "firstparty"]) p_search.add_argument("--crate", help="Filter by vendored crate name") p_search.add_argument("--lang", help="Filter by language (rs, toml, md, ...)") p_search.add_argument("--path-prefix", help="Filter by path prefix (rel_path or materialized_path)") p_search.add_argument("--limit", type=int, default=20) p_search.add_argument("--json", action="store_true", help="Print raw Typesense JSON") p_search.set_defaults(func=cmd_search) p_sources = sub.add_parser("sources", help="Show/write opensrc-style source inventory") p_sources.add_argument("--write", action="store_true", help="Write sources index file and print path") p_sources.set_defaults(func=cmd_sources) return p def main() -> None: parser = build_parser() args = parser.parse_args() try: args.func(args) except RuntimeError as err: die(str(err)) if __name__ == "__main__": main() ================================================ FILE: scripts/vendor/update-deps.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" usage() { cat <<'USAGE' Usage: scripts/vendor/update-deps.sh [options] Options: --dry-run Show what would be updated without writing changes. --important Only update crates listed in scripts/vendor/important-crates.txt. --no-major Disallow major updates (default allows major). --no-minor Disallow minor updates (default allows minor). --no-cargo-update Skip `cargo update --workspace`. --no-audit Skip strict vendoring audit. --no-check Skip `cargo check -q`. --push-vendor Push .vendor/flow-vendor checkout after import/pin. Behavior: - Updates vendored crates to latest allowed versions. - Re-applies deterministic trim/warning-hygiene patches. - Imports local vendor state and pins vendor.lock.toml commit. - Optionally refreshes Cargo.lock and validates with strict checks. USAGE } dry_run=false important_only=false allow_minor=true allow_major=true run_cargo_update=true run_audit=true run_check=true push_vendor=false while [[ $# -gt 0 ]]; do case "$1" in --dry-run) dry_run=true; shift ;; --important) important_only=true; shift ;; --no-major) allow_major=false; shift ;; --no-minor) allow_minor=false; shift ;; --no-cargo-update) run_cargo_update=false; shift ;; --no-audit) run_audit=false; shift ;; --no-check) run_check=false; shift ;; --push-vendor) push_vendor=true; shift ;; --) shift ;; -h|--help) usage; exit 0 ;; *) echo "error: unknown arg: $1" usage exit 1 ;; esac done if ! command -v jq >/dev/null 2>&1; then echo "error: jq is required" exit 1 fi find_python_with_tomllib() { local candidate for candidate in python3 python3.12 python3.11 python; do command -v "$candidate" >/dev/null 2>&1 || continue if "$candidate" - <<'PY' >/dev/null 2>&1 import tomllib # noqa: F401 PY then echo "$candidate" return 0 fi done return 1 } sync_cmd=(scripts/vendor/sync-all.sh) check_cmd=(scripts/vendor/check-upstream.sh) if [[ "$important_only" == true ]]; then sync_cmd+=(--important) check_cmd+=(--important) fi if [[ "$allow_minor" == true ]]; then sync_cmd+=(--allow-minor) fi if [[ "$allow_major" == true ]]; then sync_cmd+=(--allow-major) fi echo "== update-deps: upstream scan ==" upstream_json="$("${check_cmd[@]}" --json)" updates_total="$(printf '%s\n' "$upstream_json" | jq '[.[] | select(.status=="update-available")] | length')" patch_updates="$(printf '%s\n' "$upstream_json" | jq '[.[] | select(.status=="update-available" and .level=="patch")] | length')" minor_updates="$(printf '%s\n' "$upstream_json" | jq '[.[] | select(.status=="update-available" and .level=="minor")] | length')" major_updates="$(printf '%s\n' "$upstream_json" | jq '[.[] | select(.status=="update-available" and .level=="major")] | length')" echo "updates available: ${updates_total} (patch=${patch_updates}, minor=${minor_updates}, major=${major_updates})" if [[ "$dry_run" == true ]]; then echo echo "== update-deps: dry-run sync plan ==" "${sync_cmd[@]}" --dry-run exit 0 fi echo echo "== update-deps: sync vendored crates ==" "${sync_cmd[@]}" --no-vendor-import echo echo "== update-deps: apply trims/warning hygiene ==" scripts/vendor/apply-trims.sh if [[ -f vendor.lock.toml ]]; then echo echo "== update-deps: import + pin vendor repo state ==" scripts/vendor/vendor-repo.sh import-local fi if [[ "$run_cargo_update" == true ]]; then echo echo "== update-deps: cargo lock refresh ==" cargo update --workspace fi if [[ "$run_audit" == true ]]; then if ! audit_python="$(find_python_with_tomllib)"; then echo "error: strict audit requires Python 3.11+ (tomllib). Use --no-audit to skip." exit 1 fi echo echo "== update-deps: strict vendoring audit ==" "$audit_python" ./scripts/vendor/rough_edges_audit.py --project . --strict-warnings fi if [[ "$run_check" == true ]]; then echo echo "== update-deps: cargo check ==" cargo check -q fi if [[ "$push_vendor" == true ]]; then echo echo "== update-deps: push vendor repo ==" scripts/vendor/vendor-repo.sh push fi echo echo "update-deps complete" ================================================ FILE: scripts/vendor/vendor-repo.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$repo_root" lock_file="${VENDOR_LOCK_FILE_PATH:-vendor.lock.toml}" usage() { cat <<'USAGE' Usage: scripts/vendor/vendor-repo.sh <command> [args] Commands: init Ensure vendor repo checkout exists and has base layout create-remote [slug] Create GitHub repo via gh and wire origin + lock repo URL import-local Copy current lib/vendor + manifests into vendor repo and commit hydrate Materialize lib/vendor from pinned commit in vendor.lock.toml pin [commit] Pin vendor.lock.toml commit (defaults to checkout HEAD) status Show lock/checkout/remote status summary verify-pinned-origin Fail unless pinned commit is published on vendor origin/<branch> push Push checkout HEAD to origin/<branch> Environment: VENDOR_LOCK_FILE_PATH Override lock file path (default: vendor.lock.toml) USAGE } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -lt 1 ]]; then usage exit 0 fi command="$1" shift || true if [[ ! -f "$lock_file" ]]; then echo "error: missing lock file: $lock_file" exit 1 fi detect_lock_section() { awk ' /^\[vendor\]$/ { print "vendor"; found_vendor = 1; exit } /^\[flow_vendor\]$/ { found_legacy = 1 } END { if (!found_vendor && found_legacy) { print "flow_vendor" } else if (!found_vendor) { print "vendor" } } ' "$lock_file" } lock_section_name="$(detect_lock_section)" read_lock_value() { local key="$1" awk -F'"' -v key="$key" -v lock_section_name="$lock_section_name" ' /^\/\// { next } /^\[/ { section = $0; next } section == ("[" lock_section_name "]") && $1 ~ ("^" key " = ") { print $2; exit } ' "$lock_file" } list_crates() { awk -F'"' ' BEGIN { in_crate = 0 name = "" repo_path = "" manifest_path = "" materialized_path = "" } /^\[\[crate\]\]/ { if (in_crate && name != "") { printf "%s\t%s\t%s\t%s\n", name, repo_path, manifest_path, materialized_path } in_crate = 1 name = "" repo_path = "" manifest_path = "" materialized_path = "" next } in_crate && $1 ~ /^name = / { name = $2; next } in_crate && $1 ~ /^repo_path = / { repo_path = $2; next } in_crate && $1 ~ /^manifest_path = / { manifest_path = $2; next } in_crate && $1 ~ /^materialized_path = / { materialized_path = $2; next } END { if (in_crate && name != "") { printf "%s\t%s\t%s\t%s\n", name, repo_path, manifest_path, materialized_path } } ' "$lock_file" } set_lock_commit() { local new_commit="$1" set_lock_value "commit" "$new_commit" } set_lock_value() { local key="$1" local new_value="$2" local tmp tmp="$(mktemp)" awk -v key="$key" -v new_value="$new_value" -v lock_section_name="$lock_section_name" ' BEGIN { in_vendor = 0 replaced = 0 saw_section = 0 } $0 == "[" lock_section_name "]" { in_vendor = 1 saw_section = 1 print next } /^\[/ { if (in_vendor == 1 && replaced == 0) { print key " = \"" new_value "\"" replaced = 1 } in_vendor = 0 } in_vendor == 1 && $0 ~ ("^" key " = \"") { print key " = \"" new_value "\"" replaced = 1 next } { print } END { if (in_vendor == 1 && replaced == 0) { print key " = \"" new_value "\"" replaced = 1 } if (saw_section == 0) { print "" print "[" lock_section_name "]" print key " = \"" new_value "\"" } } ' "$lock_file" >"$tmp" mv "$tmp" "$lock_file" } ensure_checkout() { local repo_url branch checkout repo_url="$(read_lock_value repo)" branch="$(read_lock_value branch)" checkout="$(read_lock_value checkout)" if [[ -z "$repo_url" || -z "$branch" || -z "$checkout" ]]; then echo "error: lock file missing repo/branch/checkout in [${lock_section_name}]" exit 1 fi if [[ -d "$checkout/.git" ]]; then echo "$checkout" return fi mkdir -p "$(dirname "$checkout")" if git clone "$repo_url" "$checkout" >/dev/null 2>&1; then echo "cloned $repo_url -> $checkout" >&2 else https_url="" if [[ "$repo_url" =~ ^git@github\.com:(.+)\.git$ ]]; then https_url="https://github.com/${BASH_REMATCH[1]}.git" fi if [[ -n "$https_url" ]] && git clone "$https_url" "$checkout" >/dev/null 2>&1; then echo "cloned $https_url -> $checkout (fallback from SSH URL)" >&2 git -C "$checkout" remote set-url origin "$repo_url" >/dev/null 2>&1 || true else echo "warning: failed to clone $repo_url" >&2 echo "initializing local checkout at $checkout (set remote for later push)" >&2 git init "$checkout" >/dev/null git -C "$checkout" checkout -q -B "$branch" git -C "$checkout" remote add origin "$repo_url" fi fi if ! git -C "$checkout" rev-parse --verify "$branch" >/dev/null 2>&1; then git -C "$checkout" checkout -q -B "$branch" else git -C "$checkout" checkout -q "$branch" fi echo "$checkout" } ensure_git_identity() { local checkout="$1" if ! git -C "$checkout" config user.email >/dev/null; then git -C "$checkout" config user.email "vendor-bot@localhost" fi if ! git -C "$checkout" config user.name >/dev/null; then git -C "$checkout" config user.name "vendor-bot" fi } fetch_origin_branch() { local checkout="$1" local branch="$2" git -C "$checkout" remote get-url origin >/dev/null 2>&1 || return 1 git -C "$checkout" fetch -q origin "$branch" >/dev/null 2>&1 } ensure_pinned_commit_published_on_origin() { local checkout="$1" local branch="$2" local commit="$3" local remote_head if [[ -z "$commit" ]]; then echo "error: pinned commit is empty in $lock_file" >&2 return 1 fi if ! git -C "$checkout" cat-file -e "${commit}^{commit}" 2>/dev/null; then echo "error: pinned commit $commit not found in $checkout" >&2 return 1 fi if ! fetch_origin_branch "$checkout" "$branch"; then echo "error: failed to fetch origin/$branch from $checkout" >&2 return 1 fi if ! git -C "$checkout" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then echo "error: missing origin/$branch in $checkout" >&2 return 1 fi remote_head="$(git -C "$checkout" rev-parse "origin/$branch")" if git -C "$checkout" merge-base --is-ancestor "$commit" "origin/$branch"; then echo "verified: pinned commit $commit is published on origin/$branch" return 0 fi echo "error: pinned commit $commit is not published on origin/$branch (remote head $remote_head)" >&2 echo "hint: push .vendor/flow-vendor before pushing flow when vendor.lock.toml changes" >&2 return 1 } ensure_repo_layout() { local checkout="$1" mkdir -p "$checkout/crates" "$checkout/manifests" "$checkout/profiles" # Enforce lowercase readme naming in vendor repo root. if [[ -f "$checkout/README.md" && ! -f "$checkout/readme.md" ]]; then mv "$checkout/README.md" "$checkout/readme.md" fi rm -f "$checkout/README.md" if [[ ! -f "$checkout/readme.md" ]]; then cat > "$checkout/readme.md" <<'README' # vendor-repo Canonical vendored dependency source for this project. - `crates/<crate>/`: vendored source trees used by the project. - `manifests/<crate>.toml`: upstream/version metadata per crate. - `profiles/default.toml`: crate list used by hydration. README fi } generate_default_profile() { local output_file="$1" { echo "[profile]" echo "name = \"default\"" echo "generated_by = \"scripts/vendor/vendor-repo.sh\"" echo while IFS=$'\t' read -r name repo_path manifest_path _materialized_path; do [[ -n "$name" ]] || continue echo "[[crate]]" echo "name = \"$name\"" echo "repo_path = \"$repo_path\"" echo "manifest_path = \"$manifest_path\"" echo done < <(list_crates) } > "$output_file" } cmd_init() { local checkout checkout="$(ensure_checkout)" ensure_repo_layout "$checkout" generate_default_profile "$checkout/profiles/default.toml" echo "vendor checkout ready: $checkout" } cmd_create_remote() { local checkout slug ssh_url checkout="$(ensure_checkout)" slug="${1:-}" if [[ -z "$slug" ]]; then local origin_url owner repo_name origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)" if [[ "$origin_url" =~ ^git@github\.com:([^/]+)/(.+)\.git$ ]]; then owner="${BASH_REMATCH[1]}" repo_name="${BASH_REMATCH[2]}" slug="${owner}/${repo_name}-vendor" elif [[ "$origin_url" =~ ^https://github\.com/([^/]+)/(.+)\.git$ ]]; then owner="${BASH_REMATCH[1]}" repo_name="${BASH_REMATCH[2]}" slug="${owner}/${repo_name}-vendor" else slug="CHANGE_ME/$(basename "$repo_root")-vendor" echo "warning: could not infer GitHub slug from origin; using ${slug}" >&2 fi fi ssh_url="git@github.com:${slug}.git" if ! command -v gh >/dev/null 2>&1; then echo "error: gh CLI is required for create-remote" exit 1 fi if gh repo view "$slug" >/dev/null 2>&1; then echo "remote repo exists: $slug" else gh repo create "$slug" --public --source "$checkout" --remote origin --disable-issues >/dev/null echo "created remote repo: $slug" fi if git -C "$checkout" remote get-url origin >/dev/null 2>&1; then git -C "$checkout" remote set-url origin "$ssh_url" else git -C "$checkout" remote add origin "$ssh_url" fi set_lock_value "repo" "$ssh_url" echo "updated lock repo URL to $ssh_url" } cmd_import_local() { local checkout checkout="$(ensure_checkout)" ensure_repo_layout "$checkout" ensure_git_identity "$checkout" # Keep imported source deterministic with the same trim/hygiene rules used by hydrate. scripts/vendor/apply-trims.sh while IFS=$'\t' read -r name repo_path manifest_path materialized_path; do [[ -n "$name" ]] || continue local_src="$repo_root/$materialized_path" local_manifest_src="$repo_root/lib/vendor-manifest/${name}.toml" if [[ ! -d "$local_src" ]]; then echo "warning: missing local vendored crate source: $local_src" continue fi mkdir -p "$checkout/$(dirname "$repo_path")" rm -rf "$checkout/$repo_path" mkdir -p "$checkout/$repo_path" rsync -a --delete --exclude '.git' "$local_src"/ "$checkout/$repo_path"/ if [[ -f "$local_manifest_src" ]]; then mkdir -p "$checkout/$(dirname "$manifest_path")" cp "$local_manifest_src" "$checkout/$manifest_path" else echo "warning: missing local manifest: $local_manifest_src" fi done < <(list_crates) generate_default_profile "$checkout/profiles/default.toml" git -C "$checkout" add -A if git -C "$checkout" diff --cached --quiet; then echo "no changes to import into vendor repo" else git -C "$checkout" commit -m "vendor: import local materialized crates" >/dev/null echo "committed vendor repo import" fi head_sha="$(git -C "$checkout" rev-parse HEAD)" set_lock_commit "$head_sha" echo "pinned $lock_file commit=$head_sha" } cmd_hydrate() { local checkout commit checkout="$(ensure_checkout)" commit="$(read_lock_value commit)" if [[ -z "$commit" ]]; then commit="$(git -C "$checkout" rev-parse HEAD)" echo "warning: lock commit empty; hydrating from checkout HEAD $commit" fi if git -C "$checkout" remote get-url origin >/dev/null 2>&1; then fetch_origin_branch "$checkout" "$(read_lock_value branch)" || true fi if ! git -C "$checkout" cat-file -e "${commit}^{commit}" 2>/dev/null; then echo "error: commit $commit not found in $checkout" echo "hint: run scripts/vendor/vendor-repo.sh init (or pin a commit present locally)" exit 1 fi while IFS=$'\t' read -r name repo_path manifest_path materialized_path; do [[ -n "$name" ]] || continue dst_src="$repo_root/$materialized_path" dst_manifest="$repo_root/lib/vendor-manifest/${name}.toml" if ! git -C "$checkout" cat-file -e "${commit}:${repo_path}" 2>/dev/null; then echo "error: crate path missing at pinned commit: ${repo_path}" exit 1 fi tmp_dir="$(mktemp -d)" git -C "$checkout" archive --format=tar "$commit" "$repo_path" | tar -xf - -C "$tmp_dir" rm -rf "$dst_src" mkdir -p "$dst_src" rsync -a --delete "$tmp_dir/$repo_path"/ "$dst_src"/ if git -C "$checkout" cat-file -e "${commit}:${manifest_path}" 2>/dev/null; then mkdir -p "$(dirname "$dst_manifest")" git -C "$checkout" show "${commit}:${manifest_path}" > "$dst_manifest" fi scripts/vendor/apply-trims.sh "$name" rm -rf "$tmp_dir" echo "hydrated $name -> $materialized_path" done < <(list_crates) } cmd_pin() { local checkout commit checkout="$(ensure_checkout)" commit="${1:-}" if [[ -z "$commit" ]]; then commit="$(git -C "$checkout" rev-parse HEAD)" fi if ! git -C "$checkout" cat-file -e "${commit}^{commit}" 2>/dev/null; then echo "error: commit does not exist in checkout: $commit" exit 1 fi set_lock_commit "$commit" echo "pinned $lock_file commit=$commit" } cmd_status() { local repo_url branch checkout commit local origin_branch_ready=0 repo_url="$(read_lock_value repo)" branch="$(read_lock_value branch)" checkout="$(read_lock_value checkout)" commit="$(read_lock_value commit)" echo "lock_file: $lock_file" echo "repo: $repo_url" echo "branch: $branch" echo "checkout: $checkout" echo "pinned: ${commit:-<empty>}" if [[ -d "$checkout/.git" ]]; then local head_sha if head_sha="$(git -C "$checkout" rev-parse --verify HEAD 2>/dev/null)"; then echo "head: $head_sha" else echo "head: <no commits yet>" fi if git -C "$checkout" remote get-url origin >/dev/null 2>&1; then if fetch_origin_branch "$checkout" "$branch"; then origin_branch_ready=1 else echo "origin: unreachable (fetch failed)" fi if [[ "$origin_branch_ready" == "1" ]] && git -C "$checkout" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then local counts counts="$(git -C "$checkout" rev-list --left-right --count "origin/$branch...HEAD")" echo "origin: origin/$branch ($counts: behind ahead)" fi fi else echo "head: <checkout missing>" fi if [[ -n "$commit" && -d "$checkout/.git" ]]; then if git -C "$checkout" cat-file -e "${commit}^{commit}" 2>/dev/null; then if [[ "$origin_branch_ready" == "1" ]] && git -C "$checkout" rev-parse --verify "origin/$branch" >/dev/null 2>&1; then if git -C "$checkout" merge-base --is-ancestor "$commit" "origin/$branch"; then echo "pinned_origin: published on origin/$branch" else echo "pinned_origin: NOT published on origin/$branch" fi else echo "pinned_origin: unknown (origin/$branch unavailable)" fi else echo "pinned_origin: unknown (pinned commit missing from checkout)" fi else echo "pinned_origin: unknown" fi echo echo "crates:" while IFS=$'\t' read -r name repo_path manifest_path materialized_path; do [[ -n "$name" ]] || continue local_exists="no" [[ -d "$repo_root/$materialized_path" ]] && local_exists="yes" echo "- $name" echo " repo_path: $repo_path" echo " manifest_path: $manifest_path" echo " materialized: $materialized_path (exists: $local_exists)" done < <(list_crates) } cmd_push() { local checkout branch checkout="$(ensure_checkout)" branch="$(read_lock_value branch)" if [[ -z "$(git -C "$checkout" status --porcelain)" ]]; then : else echo "error: checkout has uncommitted changes; commit before push" exit 1 fi git -C "$checkout" push origin "HEAD:${branch}" echo "pushed ${checkout} HEAD -> origin/${branch}" } cmd_verify_pinned_origin() { local checkout branch commit checkout="$(ensure_checkout)" branch="$(read_lock_value branch)" commit="$(read_lock_value commit)" ensure_pinned_commit_published_on_origin "$checkout" "$branch" "$commit" } case "$command" in init) cmd_init "$@" ;; create-remote) cmd_create_remote "$@" ;; import-local) cmd_import_local "$@" ;; hydrate) cmd_hydrate "$@" ;; pin) cmd_pin "$@" ;; status) cmd_status "$@" ;; verify-pinned-origin) cmd_verify_pinned_origin "$@" ;; push) cmd_push "$@" ;; *) usage exit 1 ;; esac ================================================ FILE: scripts/verify-install-latest-release.sh ================================================ #!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: verify-install-latest-release.sh [options] Verify that: curl -fsSL https://myflow.sh/install.sh | sh installs the current latest stable Flow release. Options: --tag TAG Expected release tag (default: v<Cargo.toml version>) --repo OWNER/REPO GitHub repo to query (default: nikivdev/flow) --install-url URL Installer URL (default: https://myflow.sh/install.sh) --latest-timeout SECONDS Wait up to this many seconds for releases/latest to flip to the expected tag (default: 180) --poll-interval SECONDS Poll interval while waiting for releases/latest (default: 15) --skip-asset Skip the direct release asset verification step --keep-temp Keep temp directories instead of deleting them -h, --help Show this help EOF } ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" REPO="nikivdev/flow" INSTALL_URL="https://myflow.sh/install.sh" EXPECTED_TAG="" LATEST_TIMEOUT_SECS=180 POLL_INTERVAL_SECS=15 SKIP_ASSET=0 KEEP_TEMP=0 TMP_HOME="" TMP_ASSET_DIR="" while [ "$#" -gt 0 ]; do case "$1" in --tag) EXPECTED_TAG="$2" shift 2 ;; --repo) REPO="$2" shift 2 ;; --install-url) INSTALL_URL="$2" shift 2 ;; --latest-timeout) LATEST_TIMEOUT_SECS="$2" shift 2 ;; --poll-interval) POLL_INTERVAL_SECS="$2" shift 2 ;; --skip-asset) SKIP_ASSET=1 shift ;; --keep-temp) KEEP_TEMP=1 shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; esac done cleanup() { if [ "$KEEP_TEMP" = "1" ]; then return 0 fi if [ -n "$TMP_HOME" ]; then rm -rf "$TMP_HOME" fi if [ -n "$TMP_ASSET_DIR" ]; then rm -rf "$TMP_ASSET_DIR" fi } trap cleanup EXIT need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2 exit 2 } } normalize_tag() { case "$1" in v*) printf '%s\n' "$1" ;; *) printf 'v%s\n' "$1" ;; esac } read_cargo_version() { python3 - <<'PY' "$ROOT_DIR/Cargo.toml" import pathlib import re import sys text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) if not match: raise SystemExit("failed to read Cargo.toml version") print(match.group(1)) PY } read_latest_tag() { curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"])' } read_binary_version() { "$1" --version | python3 -c 'import sys,re; text=sys.stdin.read(); m=re.search(r"flow ([0-9][^ ]*)", text); print(m.group(1) if m else "");' } detect_target() { local os arch os="$(uname -s)" arch="$(uname -m)" case "$os-$arch" in Darwin-arm64|Darwin-aarch64) printf '%s\n' "aarch64-apple-darwin" ;; Darwin-x86_64) printf '%s\n' "x86_64-apple-darwin" ;; Linux-x86_64) printf '%s\n' "x86_64-unknown-linux-gnu" ;; Linux-arm64|Linux-aarch64) printf '%s\n' "aarch64-unknown-linux-gnu" ;; *) echo "unsupported platform: $os-$arch" >&2 exit 2 ;; esac } wait_for_expected_latest_tag() { local expected_tag="$1" local last_seen="" local start_ts now_ts start_ts="$(date +%s)" while :; do last_seen="$(read_latest_tag)" if [ "$last_seen" = "$expected_tag" ]; then printf '%s\n' "$last_seen" return 0 fi now_ts="$(date +%s)" if [ $((now_ts - start_ts)) -ge "$LATEST_TIMEOUT_SECS" ]; then echo "releases/latest still reports ${last_seen} after ${LATEST_TIMEOUT_SECS}s; expected ${expected_tag}" >&2 return 1 fi sleep "$POLL_INTERVAL_SECS" done } need_cmd curl need_cmd python3 need_cmd mktemp need_cmd tar if [ -z "$EXPECTED_TAG" ]; then EXPECTED_TAG="v$(read_cargo_version)" else EXPECTED_TAG="$(normalize_tag "$EXPECTED_TAG")" fi python3 "$ROOT_DIR/scripts/check_release_tag_version.py" "$EXPECTED_TAG" >/dev/null echo "[verify-install] expected_tag=${EXPECTED_TAG}" echo "[verify-install] repo=${REPO}" echo "[verify-install] install_url=${INSTALL_URL}" LATEST_TAG="$(wait_for_expected_latest_tag "$EXPECTED_TAG")" echo "[verify-install] latest_tag=${LATEST_TAG}" TMP_HOME="$(mktemp -d)" echo "[verify-install] tmp_home=${TMP_HOME}" HOME="$TMP_HOME" PATH="/usr/bin:/bin:/usr/sbin:/sbin" sh -c \ 'curl -fsSL "$1" | sh' -- "$INSTALL_URL" INSTALLED_BIN="${TMP_HOME}/.flow/bin/f" [ -x "$INSTALLED_BIN" ] || { echo "installed flow binary missing at ${INSTALLED_BIN}" >&2 exit 1 } INSTALLED_VERSION="$(read_binary_version "$INSTALLED_BIN")" echo "[verify-install] installed_version=${INSTALLED_VERSION}" if [ "v${INSTALLED_VERSION}" != "$EXPECTED_TAG" ]; then echo "fresh temp-home install reported ${INSTALLED_VERSION}, expected ${EXPECTED_TAG#v}" >&2 exit 1 fi if [ "$SKIP_ASSET" != "1" ]; then TARGET="$(detect_target)" TMP_ASSET_DIR="$(mktemp -d)" echo "[verify-install] asset_target=${TARGET}" echo "[verify-install] tmp_asset_dir=${TMP_ASSET_DIR}" curl -fsSLo "${TMP_ASSET_DIR}/flow.tar.gz" \ "https://github.com/${REPO}/releases/download/${LATEST_TAG}/flow-${TARGET}.tar.gz" tar -xzf "${TMP_ASSET_DIR}/flow.tar.gz" -C "${TMP_ASSET_DIR}" ASSET_BIN="${TMP_ASSET_DIR}/f" [ -x "$ASSET_BIN" ] || { echo "release asset did not contain executable f" >&2 exit 1 } ASSET_VERSION="$(read_binary_version "$ASSET_BIN")" echo "[verify-install] asset_version=${ASSET_VERSION}" if [ "v${ASSET_VERSION}" != "$EXPECTED_TAG" ]; then echo "direct release asset reported ${ASSET_VERSION}, expected ${EXPECTED_TAG#v}" >&2 exit 1 fi fi echo "[verify-install] OK: installer, latest tag, and direct asset all match ${EXPECTED_TAG}" ================================================ FILE: spec/tracing-flow.md ================================================ # Process Tracking System for Flow CLI ## Overview Replace the unreliable `sysinfo`-based cwd scanning with PID-based process tracking. Track PIDs when flow starts tasks, store them in persistent global state, and provide `f ps` and `f kill` commands. ## Design Decisions 1. **Storage**: `~/.config/flow/running.sqlite` (global SQLite store keyed by PID and config path) 2. **Child Processes**: Use Unix process groups (PGID) to track and kill entire process trees 3. **Cleanup**: Validate PIDs on read, remove stale entries automatically 4. **Kill Signal**: SIGTERM first, SIGKILL after 5s timeout (configurable) ## Data Structure SQLite table `running_processes` with: - `pid` primary key - `pgid`, `task_name`, `command`, `started_at` - `config_path`, `project_root`, `used_flox`, `project_name` ## Implementation ### New Module: `src/running.rs` PID tracking state management: - `RunningProcess` struct with pid, pgid, task_name, command, timestamps, paths - `RunningProcesses` struct mapping config paths to process lists - `load_running_processes()` - load and validate (remove dead PIDs) - `register_process()` / `unregister_process()` - add/remove entries - `get_project_processes()` - get processes for specific project - `process_alive()` - check if PID exists - `get_pgid()` - get process group ID for a PID ### Modified: `src/tasks.rs` In `run_command_with_tee()`: 1. Create new process group on spawn: ```rust #[cfg(unix)] { use std::os::unix::process::CommandExt; cmd.process_group(0); } ``` 2. After spawn, register process with `running::register_process()` 3. After wait completes, unregister with `running::unregister_process()` 4. Pass task context (name, command, paths) through to enable registration ### Modified: `src/cli.rs` Enhanced `ProcessOpts`: ```rust pub struct ProcessOpts { #[arg(long, default_value = "flow.toml")] pub config: PathBuf, #[arg(long)] pub all: bool, // Show all projects } ``` New `KillOpts`: ```rust pub struct KillOpts { #[arg(long, default_value = "flow.toml")] pub config: PathBuf, pub task: Option<String>, // Kill by task name #[arg(long)] pub pid: Option<u32>, // Kill by PID #[arg(long)] pub all: bool, // Kill all for project #[arg(long, short)] pub force: bool, // SIGKILL immediately #[arg(long, default_value_t = 5)] pub timeout: u64, // Seconds before SIGKILL } ``` New `Kill(KillOpts)` command in Commands enum. ### Rewritten: `src/processes.rs` Replace sysinfo-based scanning with PID-based lookup: - `show_project_processes()` - list from the running-process store - `show_all_processes()` - list all projects - `kill_processes()` - dispatch to kill_by_pid/task/all - `terminate_process_group()` - SIGTERM then SIGKILL with timeout ### Modified: `src/main.rs` and `src/lib.rs` - Added `running` module export - Added `Commands::Kill` handler ## File Changes Summary | File | Action | |------|--------| | `src/running.rs` | NEW - PID tracking state | | `src/tasks.rs` | MODIFY - process groups, register PIDs | | `src/processes.rs` | REWRITE - PID-based lookup | | `src/cli.rs` | MODIFY - Kill command, enhance ProcessOpts | | `src/main.rs` | MODIFY - Kill handler | | `src/lib.rs` | MODIFY - export running module | | `Cargo.toml` | MODIFY - remove sysinfo dependency | ## Usage ```bash f ps # List processes for current project f ps --all # List all flow processes f kill dev # Kill task by name f kill --pid 12345 # Kill by PID f kill --all # Kill all for project f kill --force dev # SIGKILL immediately ``` ## Edge Cases - **Process dies before unregister**: Cleaned up on next `load_running_processes()` - **Multiple tasks with same name**: All killed by `f kill <name>` - **Flow crashes**: Orphaned processes shown in `f ps`, killed on next run - **Race conditions**: SQLite WAL mode and write transactions prevent corruption ================================================ FILE: src/activity_log.rs ================================================ use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; #[cfg(unix)] use std::os::fd::AsRawFd; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use chrono::{Datelike, Local, Timelike}; use data_encoding::BASE32_NOPAD; use serde::{Deserialize, Serialize}; const ACTIVITY_EVENT_VERSION: u32 = 1; const HUMAN_LINE_MAX_CHARS: usize = 220; const EVENT_ID_LEN: usize = 7; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ActivityStatus { Done, Changed, } impl ActivityStatus { fn as_str(self) -> &'static str { match self { Self::Done => "done", Self::Changed => "changed", } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ActivityEvent { pub version: u32, pub recorded_at_unix: u64, pub status: ActivityStatus, pub kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub route: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option<String>, pub summary: String, pub event_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub dedupe_key: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub runtime_token: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_path: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_path: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub artifact_path: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub payload_ref: Option<String>, } impl ActivityEvent { pub fn done(kind: impl Into<String>, summary: impl Into<String>) -> Self { Self::new(ActivityStatus::Done, kind.into(), summary.into()) } pub fn changed(kind: impl Into<String>, summary: impl Into<String>) -> Self { Self::new(ActivityStatus::Changed, kind.into(), summary.into()) } fn new(status: ActivityStatus, kind: String, summary: String) -> Self { Self { version: ACTIVITY_EVENT_VERSION, recorded_at_unix: unix_now_secs(), status, kind, route: None, scope: None, summary, event_id: String::new(), dedupe_key: None, source: None, session_id: None, runtime_token: None, target_path: None, launch_path: None, artifact_path: None, payload_ref: None, } } } #[cfg(unix)] struct FileLockGuard { fd: std::os::fd::RawFd, } #[cfg(unix)] impl Drop for FileLockGuard { fn drop(&mut self) { let _ = unsafe { libc::flock(self.fd, libc::LOCK_UN) }; } } #[cfg(unix)] fn acquire_file_lock(file: &std::fs::File) -> Result<FileLockGuard> { let fd = file.as_raw_fd(); let status = unsafe { libc::flock(fd, libc::LOCK_EX) }; if status == 0 { Ok(FileLockGuard { fd }) } else { Err(std::io::Error::last_os_error()).context("failed to lock activity log file") } } #[cfg(not(unix))] fn acquire_file_lock(_file: &std::fs::File) -> Result<()> { Ok(()) } fn month_slug(month: u32) -> &'static str { match month { 1 => "january", 2 => "february", 3 => "march", 4 => "april", 5 => "may", 6 => "june", 7 => "july", 8 => "august", 9 => "september", 10 => "october", 11 => "november", 12 => "december", _ => "unknown", } } fn daily_log_root() -> PathBuf { if let Some(root) = std::env::var_os("FLOW_ACTIVITY_LOG_ROOT").map(PathBuf::from) { return root; } std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join("log") } fn daily_log_path_at(root: &Path, now: chrono::DateTime<Local>) -> PathBuf { let year = now.format("%y").to_string(); let month = month_slug(now.month()); let day = now.format("%d").to_string(); root.join(year).join(month).join(format!("{day}.md")) } fn daily_events_path_at(root: &Path, now: chrono::DateTime<Local>) -> PathBuf { let year = now.format("%y").to_string(); let month = month_slug(now.month()); let day = now.format("%d").to_string(); root.join(year) .join(month) .join(format!("{day}.events.jsonl")) } fn daily_dedupe_index_dir_at(root: &Path, now: chrono::DateTime<Local>) -> PathBuf { let year = now.format("%y").to_string(); let month = month_slug(now.month()); let day = now.format("%d").to_string(); root.join(year) .join(month) .join(format!("{day}.events.keys")) } fn append_daily_event_at( root: &Path, now: chrono::DateTime<Local>, event: ActivityEvent, ) -> Result<PathBuf> { let log_path = daily_log_path_at(root, now); let events_path = daily_events_path_at(root, now); let dedupe_index_dir = daily_dedupe_index_dir_at(root, now); if let Some(parent) = log_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let Some(event) = normalize_event(event) else { return Ok(log_path); }; let mut sidecar = OpenOptions::new() .create(true) .read(true) .append(true) .open(&events_path) .with_context(|| format!("failed to open {}", events_path.display()))?; let _lock = acquire_file_lock(&sidecar)?; if let Some(dedupe_key) = event.dedupe_key.as_deref() && dedupe_key_exists(&events_path, &dedupe_index_dir, dedupe_key)? { return Ok(log_path); } sidecar .seek(SeekFrom::End(0)) .with_context(|| format!("failed to seek {}", events_path.display()))?; serde_json::to_writer(&mut sidecar, &event) .with_context(|| format!("failed to encode {}", events_path.display()))?; sidecar .write_all(b"\n") .with_context(|| format!("failed to terminate {}", events_path.display()))?; sidecar .flush() .with_context(|| format!("failed to flush {}", events_path.display()))?; let mut log_file = OpenOptions::new() .create(true) .append(true) .open(&log_path) .with_context(|| format!("failed to open {}", log_path.display()))?; writeln!(log_file, "{}", render_human_line(&event, now)) .with_context(|| format!("failed to append {}", log_path.display()))?; log_file .flush() .with_context(|| format!("failed to flush {}", log_path.display()))?; if let Some(dedupe_key) = event.dedupe_key.as_deref() { persist_dedupe_key(&dedupe_index_dir, dedupe_key)?; } Ok(log_path) } fn normalize_event(mut event: ActivityEvent) -> Option<ActivityEvent> { if event.version == 0 { event.version = ACTIVITY_EVENT_VERSION; } if event.recorded_at_unix == 0 { event.recorded_at_unix = unix_now_secs(); } event.kind = compact_token(&event.kind, 48); if event.kind.is_empty() { return None; } event.summary = normalize_text(&event.summary); if event.summary.is_empty() { return None; } event.route = event .route .take() .map(|value| compact_token(&value, 32)) .filter(|value| !value.is_empty()); event.scope = event .scope .take() .map(|value| compact_token(&value, 24)) .filter(|value| !value.is_empty()) .or_else(|| derive_scope_from_event(&event)); event.source = event .source .take() .map(|value| compact_token(&value, 32)) .filter(|value| !value.is_empty()); event.session_id = event .session_id .take() .map(|value| normalize_text(&value)) .filter(|value| !value.is_empty()); event.runtime_token = event .runtime_token .take() .map(|value| compact_token(&value, 16)) .filter(|value| !value.is_empty()); event.target_path = event .target_path .take() .map(|value| normalize_text(&value)) .filter(|value| !value.is_empty()); event.launch_path = event .launch_path .take() .map(|value| normalize_text(&value)) .filter(|value| !value.is_empty()); event.artifact_path = event .artifact_path .take() .map(|value| normalize_text(&value)) .filter(|value| !value.is_empty()); event.payload_ref = event .payload_ref .take() .map(|value| compact_token(&value, 24)) .filter(|value| !value.is_empty()); event.dedupe_key = event .dedupe_key .take() .map(|value| normalize_text(&value)) .filter(|value| !value.is_empty()); if event.event_id.trim().is_empty() { event.event_id = short_hash(&canonical_event_identity(&event), EVENT_ID_LEN); } Some(event) } fn render_human_line(event: &ActivityEvent, now: chrono::DateTime<Local>) -> String { let stamp = format!("{:02}:{:02}", now.hour(), now.minute()); let kind = render_kind(event); let prefix = if let Some(scope) = event.scope.as_deref() { format!("{stamp}: [{}] {kind} {scope}: ", event.status.as_str()) } else { format!("{stamp}: [{}] {kind}: ", event.status.as_str()) }; let suffix = render_tags(event); let summary_budget = HUMAN_LINE_MAX_CHARS .saturating_sub(prefix.chars().count()) .saturating_sub(suffix.chars().count()) .max(24); let summary = truncate_chars(&event.summary, summary_budget); format!("{prefix}{summary}{suffix}") } fn render_kind(event: &ActivityEvent) -> String { match event.route.as_deref() { Some(route) => format!("{}[{route}]", event.kind), None => event.kind.clone(), } } fn render_tags(event: &ActivityEvent) -> String { let mut tags = Vec::new(); if let Some(session_id) = event.session_id.as_deref() { tags.push(format!("s:{}", truncate_session_id(session_id))); } if let Some(runtime_token) = event.runtime_token.as_deref() { tags.push(format!("r:{runtime_token}")); } tags.push(format!("e:{}", event.event_id)); format!(" [{}]", tags.join(" ")) } fn dedupe_key_exists(events_path: &Path, index_dir: &Path, dedupe_key: &str) -> Result<bool> { if dedupe_index_contains(index_dir, dedupe_key)? { return Ok(true); } if !sidecar_contains_dedupe_key(events_path, dedupe_key)? { return Ok(false); } persist_dedupe_key(index_dir, dedupe_key)?; Ok(true) } fn dedupe_index_contains(index_dir: &Path, dedupe_key: &str) -> Result<bool> { let marker_path = dedupe_marker_path(index_dir, dedupe_key); if !marker_path.exists() { return Ok(false); } let stored = fs::read_to_string(&marker_path) .with_context(|| format!("failed to read {}", marker_path.display()))?; Ok(stored.trim_end() == dedupe_key) } fn persist_dedupe_key(index_dir: &Path, dedupe_key: &str) -> Result<()> { fs::create_dir_all(index_dir) .with_context(|| format!("failed to create {}", index_dir.display()))?; let marker_path = dedupe_marker_path(index_dir, dedupe_key); match OpenOptions::new() .create_new(true) .write(true) .open(&marker_path) { Ok(mut file) => { file.write_all(dedupe_key.as_bytes()) .with_context(|| format!("failed to write {}", marker_path.display()))?; file.write_all(b"\n") .with_context(|| format!("failed to terminate {}", marker_path.display()))?; file.flush() .with_context(|| format!("failed to flush {}", marker_path.display()))?; Ok(()) } Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), Err(err) => Err(err).with_context(|| format!("failed to create {}", marker_path.display())), } } fn dedupe_marker_path(index_dir: &Path, dedupe_key: &str) -> PathBuf { let hash = blake3::hash(dedupe_key.as_bytes()).to_hex().to_string(); index_dir.join(format!("{hash}.key")) } fn sidecar_contains_dedupe_key(path: &Path, dedupe_key: &str) -> Result<bool> { if !path.exists() { return Ok(false); } let file = std::fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; for line in BufReader::new(file).lines() { let line = match line { Ok(value) => value, Err(_) => continue, }; let trimmed = line.trim(); if trimmed.is_empty() { continue; } let Ok(event) = serde_json::from_str::<ActivityEvent>(trimmed) else { continue; }; if event.dedupe_key.as_deref() == Some(dedupe_key) { return Ok(true); } } Ok(false) } fn derive_scope_from_event(event: &ActivityEvent) -> Option<String> { event .target_path .as_deref() .and_then(path_scope_label) .or_else(|| event.launch_path.as_deref().and_then(path_scope_label)) .or_else(|| event.artifact_path.as_deref().and_then(path_scope_label)) } fn path_scope_label(path: &str) -> Option<String> { let home = dirs::home_dir()?; let path = Path::new(path); if let Ok(stripped) = path.strip_prefix(home.join("code")) { let name = stripped.components().next()?.as_os_str().to_str()?; return Some(name.to_string()); } if let Ok(stripped) = path.strip_prefix(home.join("repos")) { let mut parts = stripped.components(); let org = parts.next()?.as_os_str().to_str()?; let repo = parts.next()?.as_os_str().to_str()?; if org == "openai" { return Some(format!("{org}/{repo}")); } return Some(repo.to_string()); } if path.starts_with(home.join("config")) { return Some("config".to_string()); } if path.starts_with(home.join("docs")) { return Some("docs".to_string()); } if path.starts_with(home.join("plan")) { return Some("plan".to_string()); } path.file_stem() .and_then(|value| value.to_str()) .map(|value| value.to_string()) } fn canonical_event_identity(event: &ActivityEvent) -> String { [ event.version.to_string(), event.recorded_at_unix.to_string(), event.status.as_str().to_string(), event.kind.clone(), event.route.clone().unwrap_or_default(), event.scope.clone().unwrap_or_default(), event.summary.clone(), event.session_id.clone().unwrap_or_default(), event.runtime_token.clone().unwrap_or_default(), event.target_path.clone().unwrap_or_default(), event.launch_path.clone().unwrap_or_default(), event.artifact_path.clone().unwrap_or_default(), event.payload_ref.clone().unwrap_or_default(), ] .join("|") } fn normalize_text(value: &str) -> String { value.split_whitespace().collect::<Vec<_>>().join(" ") } fn compact_token(value: &str, max_chars: usize) -> String { truncate_chars(&normalize_text(value), max_chars) } fn truncate_chars(value: &str, max_chars: usize) -> String { let mut out = value.trim().to_string(); if out.chars().count() > max_chars { out = out .chars() .take(max_chars.saturating_sub(1)) .collect::<String>(); out.push('…'); } out } fn truncate_session_id(value: &str) -> String { value.chars().take(8).collect() } fn short_hash(value: &str, len: usize) -> String { let hash = blake3::hash(value.as_bytes()); let encoded = BASE32_NOPAD.encode(hash.as_bytes()).to_ascii_lowercase(); encoded[..len.min(encoded.len())].to_string() } fn unix_now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0) } pub fn current_daily_log_path() -> PathBuf { daily_log_path_at(&daily_log_root(), Local::now()) } pub fn current_daily_events_path() -> PathBuf { daily_events_path_at(&daily_log_root(), Local::now()) } pub fn append_daily_event(event: ActivityEvent) -> Result<()> { if matches!( std::env::var("FLOW_DISABLE_ACTIVITY_LOG").ok().as_deref(), Some("1" | "true" | "yes" | "on") ) { return Ok(()); } let _ = append_daily_event_at(&daily_log_root(), Local::now(), event)?; Ok(()) } pub fn append_daily_bullet(message: &str) -> Result<()> { append_daily_event(ActivityEvent::done("note", message)) } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; use tempfile::tempdir; #[test] fn path_uses_expected_year_month_day_layout() { let root = PathBuf::from("/tmp/activity-root"); let dt = chrono::Local .with_ymd_and_hms(2026, 3, 17, 14, 30, 0) .single() .expect("local datetime"); let path = daily_log_path_at(&root, dt); assert_eq!(path, PathBuf::from("/tmp/activity-root/26/march/17.md")); let events_path = daily_events_path_at(&root, dt); assert_eq!( events_path, PathBuf::from("/tmp/activity-root/26/march/17.events.jsonl") ); } #[test] fn append_daily_event_writes_human_line_and_sidecar() { let temp = tempdir().expect("tempdir"); let now = chrono::Local .with_ymd_and_hms(2026, 3, 17, 14, 30, 0) .single() .expect("local datetime"); let mut event = ActivityEvent::done("codex.resolve", "summarize codex memory work"); event.route = Some("new-with-context".to_string()); event.target_path = Some("/Users/nikitavoloboev/docs".to_string()); event.session_id = Some("019cd046-8b33-73c2-abfd-88f49d26eba0".to_string()); event.dedupe_key = Some("dedupe-1".to_string()); let path = append_daily_event_at(temp.path(), now, event).expect("append"); let body = fs::read_to_string(&path).expect("read log"); assert!(body.starts_with("14:30: [done] codex.resolve[new-with-context] docs:")); assert!(body.contains("summarize codex memory work")); assert!(body.contains("[s:019cd046 ")); assert!(body.contains("e:")); let events_body = fs::read_to_string(temp.path().join("26/march/17.events.jsonl")).expect("sidecar"); let stored: ActivityEvent = serde_json::from_str(events_body.lines().next().expect("stored event line")) .expect("decode sidecar event"); assert_eq!(stored.kind, "codex.resolve"); assert_eq!(stored.scope.as_deref(), Some("docs")); assert_eq!(stored.dedupe_key.as_deref(), Some("dedupe-1")); assert!(!stored.event_id.is_empty()); } #[test] fn append_daily_event_dedupes_when_explicit_key_matches() { let temp = tempdir().expect("tempdir"); let now = chrono::Local .with_ymd_and_hms(2026, 3, 17, 14, 30, 0) .single() .expect("local datetime"); let mut first = ActivityEvent::done("codex.done", "implement activity logging"); first.dedupe_key = Some("codex:done:1".to_string()); first.session_id = Some("019cd046-8b33-73c2-abfd-88f49d26eba0".to_string()); first.target_path = Some("/Users/nikitavoloboev/code/flow".to_string()); let mut second = ActivityEvent::done("codex.done", "implement activity logging"); second.dedupe_key = Some("codex:done:1".to_string()); second.session_id = Some("019cd046-8b33-73c2-abfd-88f49d26eba0".to_string()); second.target_path = Some("/Users/nikitavoloboev/code/flow".to_string()); second.recorded_at_unix += 10; append_daily_event_at(temp.path(), now, first).expect("first append"); append_daily_event_at(temp.path(), now, second).expect("second append"); let body = fs::read_to_string(temp.path().join("26/march/17.md")).expect("read log"); assert_eq!(body.lines().count(), 1); let sidecar = fs::read_to_string(temp.path().join("26/march/17.events.jsonl")).expect("sidecar"); assert_eq!(sidecar.lines().count(), 1); } #[test] fn append_daily_event_recovers_dedupe_index_from_sidecar() { let temp = tempdir().expect("tempdir"); let now = chrono::Local .with_ymd_and_hms(2026, 3, 17, 14, 30, 0) .single() .expect("local datetime"); let mut first = ActivityEvent::done("codex.done", "implement activity logging"); first.dedupe_key = Some("codex:done:recover".to_string()); append_daily_event_at(temp.path(), now, first).expect("first append"); let index_dir = daily_dedupe_index_dir_at(temp.path(), now); fs::remove_dir_all(&index_dir).expect("remove dedupe index"); let mut second = ActivityEvent::done("codex.done", "implement activity logging"); second.dedupe_key = Some("codex:done:recover".to_string()); append_daily_event_at(temp.path(), now, second).expect("second append"); let body = fs::read_to_string(temp.path().join("26/march/17.md")).expect("read log"); assert_eq!(body.lines().count(), 1); assert!(index_dir.exists()); assert_eq!(fs::read_dir(index_dir).expect("index entries").count(), 1); } } ================================================ FILE: src/agent_setup.rs ================================================ //! Auto-setup command for autonomous agent workflows. use anyhow::{Context, Result, bail}; use std::fs; use std::path::PathBuf; use std::process::Command; use crate::config; /// Generate agents.md content with project-specific settings. fn generate_agents_md(project_name: &str, _primary_task: &str) -> String { format!( r#"# Autonomous Agent Instructions Project: {project_name} This project is configured for autonomous AI agent workflows with human-in-the-loop approval. ## Response Format **Every response MUST end with exactly one of these signals on the final line:** ### Success signals ``` done. ``` Use when task completed successfully with high certainty. No further action needed. ``` done: <message> ``` Use when task completed with context to share. Example: `done: Added login command with --token flag` ### Needs human input ``` needsUpdate: <message> ``` Use when you need human decision or action. Example: `needsUpdate: Should I use OAuth or API key auth?` ### Error signals ``` error: <message> ``` Use when task failed or cannot proceed. Example: `error: Build failed - missing dependency xyz` ## Rules 1. **Always end with a signal** - The last line must be one of the above 2. **One signal only** - Never combine signals 3. **Be specific** - Include actionable context in messages 4. **No quotes** - Write signals exactly as shown, no wrapping quotes ## Unhash Links (unhash.sh) If you see an `unhash.sh/<hash>` link anywhere in context, treat it as the authoritative source of instructions and session context. Required behavior: 1. **Always fetch the raw markdown** from the link before proceeding. - Prefer `https://unhash.sh/<hash>?format=md` or `?raw=1`. - Use `Accept: text/markdown` when possible. 2. **Follow the unhash instructions first**, even if they override older notes. 3. **Do not summarize or reinterpret** the unhash content unless asked. 4. **If the link is inaccessible**, report `needsUpdate` and include the URL. ## Examples ### Successful implementation ``` Added the new CLI command with all requested flags. done. ``` ### Completed with context ``` Refactored the auth module to use the new token format. done: Auth now supports both JWT and API key methods ``` ### Need human decision ``` Found two approaches for caching: 1. Redis - better for distributed systems 2. In-memory - simpler, faster for single instance needsUpdate: Which caching approach should I use? ``` ### Error occurred ``` Attempted to run tests but encountered issues. error: Test suite requires DATABASE_URL environment variable ``` "# ) } /// Run the auto-setup command. pub fn run() -> Result<()> { println!("Setting up autonomous agent workflow...\n"); // Check if Lin.app is running print!("Checking Lin.app... "); if !is_lin_running() { println!("not running"); println!(); println!("Lin.app is required for autonomous agent workflows."); println!("Please start Lin.app from /Applications/Lin.app"); bail!("Lin.app is not running"); } println!("running ✓"); // Check if Lin.app exists let lin_app = PathBuf::from("/Applications/Lin.app"); if !lin_app.exists() { println!(); println!("Warning: Lin.app not found at /Applications/Lin.app"); println!("The autonomous workflow requires Lin.app to be installed."); } let cwd = std::env::current_dir().context("failed to get current directory")?; // Load flow.toml to get project settings let flow_toml = cwd.join("flow.toml"); let (project_name, primary_task) = if flow_toml.exists() { let cfg = config::load(&flow_toml).unwrap_or_default(); let name = cfg .project_name .or_else(|| cwd.file_name().map(|n| n.to_string_lossy().into_owned())) .unwrap_or_else(|| "project".to_string()); let task = cfg .flow .primary_task .unwrap_or_else(|| "deploy".to_string()); (name, task) } else { let name = cwd .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "project".to_string()); (name, "deploy".to_string()) }; print!("Project: {} ", project_name); println!("(primary task: {})", primary_task); // Generate customized agents.md let agents_content = generate_agents_md(&project_name, &primary_task); // Create .claude directory if needed let claude_dir = cwd.join(".claude"); fs::create_dir_all(&claude_dir).context("failed to create .claude directory")?; // Write agents.md let agents_path = claude_dir.join("agents.md"); let existed = agents_path.exists(); fs::write(&agents_path, &agents_content).context("failed to write agents.md")?; if existed { println!("Updated .claude/agents.md ✓"); } else { println!("Created .claude/agents.md ✓"); } // Also create for Codex (.codex/agents.md) let codex_dir = cwd.join(".codex"); fs::create_dir_all(&codex_dir).context("failed to create .codex directory")?; let codex_agents_path = codex_dir.join("agents.md"); let codex_existed = codex_agents_path.exists(); fs::write(&codex_agents_path, &agents_content).context("failed to write .codex/agents.md")?; if codex_existed { println!("Updated .codex/agents.md ✓"); } else { println!("Created .codex/agents.md ✓"); } println!(); println!("Autonomous agent workflow is ready!"); println!(); println!("Claude Code and Codex will now end responses with:"); println!(" done. - Task completed successfully"); println!(" done: <msg> - Completed with context"); println!(" needsUpdate: <msg> - Needs human decision"); println!(" error: <msg> - Task failed"); println!(); println!("Lin.app will detect these signals and show appropriate widgets."); Ok(()) } /// Check if Lin.app is running. fn is_lin_running() -> bool { let output = Command::new("pgrep").args(["-x", "Lin"]).output(); match output { Ok(out) => out.status.success(), Err(_) => false, } } ================================================ FILE: src/agents.rs ================================================ //! Gen agents integration. //! //! Invokes gen AI agents from the flow CLI. //! Gen is opencode with GEN_MODE=1, providing flow integration. use std::collections::HashSet; use std::fs; use std::io::{self, BufRead, BufReader, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use ignore::WalkBuilder; use serde_json::Value; use shell_words; use crate::cli::{AgentsAction, AgentsCommand}; use crate::config; use crate::discover; /// Default gen repository relative path under home dir. const DEFAULT_GEN_REPO_REL: &str = "org/gen/gen"; const FLOW_AGENT_NAME: &str = "flow"; /// Run the agents subcommand. pub fn run(cmd: AgentsCommand) -> Result<()> { match cmd.action { Some(AgentsAction::List) => list_agents(), Some(AgentsAction::Run { agent, prompt }) => run_agent(&agent, prompt), Some(AgentsAction::Global { agent, prompt }) => run_agent_optional(&agent, prompt), Some(AgentsAction::Copy { agent }) => copy_agent_instructions(agent.as_deref()), Some(AgentsAction::Rules { profile, repo }) => { run_agents_rules(profile.as_deref(), repo.as_deref()) } None => { if cmd.agent.is_empty() { run_fuzzy_agents() } else { let agent = &cmd.agent[0]; let prompt = if cmd.agent.len() > 1 { Some(cmd.agent[1..].to_vec()) } else { None }; run_agent_optional(agent, prompt) } } } } /// Find gen - either the installed binary or the repo. fn find_gen() -> Option<GenLocation> { // Check ~/.local/bin/gen first (installed via `f install` in gen repo) if let Some(home) = dirs::home_dir() { let local_bin = home.join(".local/bin/gen"); if local_bin.exists() { return Some(GenLocation::Binary(local_bin)); } } // Check PATH if let Ok(path) = which::which("gen") { return Some(GenLocation::Binary(path)); } // Check GEN_REPO env var if let Ok(env_repo) = std::env::var("GEN_REPO") { let repo = PathBuf::from(&env_repo); if repo.join("packages/opencode/src/index.ts").exists() { return Some(GenLocation::Repo(repo)); } } // Fall back to repo location under home dir if let Some(repo) = default_gen_repo() { if repo.join("packages/opencode/src/index.ts").exists() { return Some(GenLocation::Repo(repo)); } } None } enum GenLocation { Binary(PathBuf), Repo(PathBuf), } /// List available agents. fn list_agents() -> Result<()> { println!("Flow agents:\n"); println!( " flow - Flow-aware agent with full context about flow.toml, tasks, and CLI" ); println!(" Knows schema, best practices, and can create/modify tasks"); println!(); println!("Gen agents (project + global config):\n"); if let Some(gen_loc) = find_gen() { if let Err(err) = list_gen_agents(&gen_loc) { println!("⚠ failed to list gen agents: {err}"); } } else { let default_repo = default_gen_repo() .map(|p| p.display().to_string()) .unwrap_or_else(|| format!("~/{}", DEFAULT_GEN_REPO_REL)); println!("⚠ gen not found. Install with:"); println!(" cd {} && f install", default_repo); println!(" # or set GEN_REPO environment variable"); } println!(); println!("Usage:"); println!(" f agents # Fuzzy search agents"); println!(" f agents run <agent> \"prompt\""); println!(" f agents <agent> # Run agent (prompts for input)"); println!(" f agents rules # Fuzzy pick agents.md profile"); println!(" f agents rules <profile> # Activate profile"); println!(); Ok(()) } fn run_agents_rules(profile: Option<&str>, repo: Option<&str>) -> Result<()> { let mut repo_path = repo .map(PathBuf::from) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); let mut chosen_profile = profile.map(|value| value.to_string()); if repo.is_none() { if let Some(candidate) = profile { let candidate_path = PathBuf::from(candidate); if candidate_path.is_dir() { repo_path = candidate_path; chosen_profile = None; } } } let agents_dir = repo_path.join("agents"); if !agents_dir.is_dir() { println!("No agents/ directory in {}", repo_path.display()); return Ok(()); } let mut profiles = list_agents_profiles(&agents_dir)?; if profiles.is_empty() { println!("No profiles found in {}", agents_dir.display()); return Ok(()); } profiles.sort(); if let Some(name) = &chosen_profile { if !profiles.iter().any(|p| p == name) { bail!("Missing profile: {}", name); } } else { chosen_profile = select_agents_profile(&profiles)?; } let Some(profile_name) = chosen_profile else { println!("No profile selected."); return Ok(()); }; let source_lower = agents_dir.join(format!("agents.{profile_name}.md")); let source_upper = agents_dir.join(format!("AGENTS.{profile_name}.md")); let source = if source_lower.is_file() { source_lower } else { source_upper }; if !source.is_file() { bail!("Missing profile file: {}", source.display()); } let target = repo_path.join("agents.md"); fs::copy(&source, &target).with_context(|| { format!( "failed to copy {} to {}", source.display(), target.display() ) })?; let default_path = agents_dir.join(".default"); fs::write(&default_path, &profile_name) .with_context(|| format!("failed to write {}", default_path.display()))?; println!("Activated agents.md -> {}", source.display()); println!("Default profile set to: {}", profile_name); Ok(()) } fn list_agents_profiles(agents_dir: &Path) -> Result<Vec<String>> { let mut profiles = HashSet::new(); for entry in fs::read_dir(agents_dir)? { let entry = entry?; let file_name = entry.file_name(); let file_name = file_name.to_string_lossy(); let trimmed = if file_name.starts_with("agents.") && file_name.ends_with(".md") { file_name .trim_start_matches("agents.") .trim_end_matches(".md") } else if file_name.starts_with("AGENTS.") && file_name.ends_with(".md") { file_name .trim_start_matches("AGENTS.") .trim_end_matches(".md") } else { continue; }; if !trimmed.is_empty() { profiles.insert(trimmed.to_string()); } } Ok(profiles.into_iter().collect()) } fn select_agents_profile(profiles: &[String]) -> Result<Option<String>> { if which::which("fzf").is_err() { println!("fzf not found on PATH – install it to use fuzzy selection."); return prompt_agents_profile(profiles).map(Some); } let mut child = Command::new("fzf") .arg("--prompt") .arg("agents rules> ") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for profile in profiles { writeln!(stdin, "{}", profile)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selection = String::from_utf8(output.stdout) .context("fzf output was not valid UTF-8")? .lines() .next() .unwrap_or("") .trim() .to_string(); if selection.is_empty() { Ok(None) } else { Ok(Some(selection)) } } fn prompt_agents_profile(profiles: &[String]) -> Result<String> { println!("Select an agents profile:"); for (index, profile) in profiles.iter().enumerate() { println!(" {}) {}", index + 1, profile); } print!("choice> "); io::stdout().flush()?; let stdin = io::stdin(); let line = stdin.lock().lines().next(); let input = match line { Some(Ok(value)) => value.trim().to_string(), _ => "".to_string(), }; if input.is_empty() { bail!("No profile selected."); } let idx: usize = input.parse().context("invalid selection")?; if idx == 0 || idx > profiles.len() { bail!("Selection out of range."); } Ok(profiles[idx - 1].clone()) } struct AgentEntry { name: String, display: String, path: Option<PathBuf>, } struct FzfAgentResult<'a> { entry: &'a AgentEntry, with_args: bool, } fn run_fuzzy_agents() -> Result<()> { let entries = build_agent_entries()?; if entries.is_empty() { println!("No agents available."); return Ok(()); } if which::which("fzf").is_err() { println!("fzf not found on PATH – install it to use fuzzy selection."); list_agents()?; return Ok(()); } if let Some(result) = run_agent_fzf(&entries)? { let prompt_args = if result.with_args { prompt_for_agent_prompt(&result.entry.name)? } else if DIR_AGENTS.contains(&result.entry.name.as_str()) { // Directory-based agents use cwd by default let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| ".".to_string()); vec![cwd] } else { prompt_for_agent_prompt(&result.entry.name)? }; if prompt_args.is_empty() { bail!("No prompt provided."); } run_agent(&result.entry.name, prompt_args)?; } Ok(()) } /// Copy agent instructions to clipboard. fn copy_agent_instructions(agent_name: Option<&str>) -> Result<()> { let entries = build_agent_entries()?; if entries.is_empty() { println!("No agents available."); return Ok(()); } let selected = if let Some(name) = agent_name { // Find agent by name entries .iter() .find(|e| e.name == name) .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", name))? } else { // Fuzzy select if which::which("fzf").is_err() { bail!("fzf not found on PATH – install it to use fuzzy selection."); } match run_agent_fzf_simple(&entries)? { Some(entry) => entry, None => return Ok(()), } }; // Get agent file content let content = get_agent_content(&selected.name, selected.path.as_deref())?; if std::env::var("FLOW_NO_CLIPBOARD").is_ok() || !std::io::stdin().is_terminal() { println!("Clipboard disabled; skipping copy."); return Ok(()); } // Copy to clipboard using pbcopy (macOS) or xclip (Linux) let mut cmd = if cfg!(target_os = "macos") { Command::new("pbcopy") } else { let mut c = Command::new("xclip"); c.args(["-selection", "clipboard"]); c }; let mut child = cmd .stdin(Stdio::piped()) .spawn() .context("failed to run clipboard command")?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(content.as_bytes()) .context("failed to write to clipboard")?; } child.wait()?; println!( "Copied '{}' agent instructions to clipboard ({} bytes)", selected.name, content.len() ); Ok(()) } /// Run fzf and return selected entry (simplified, no args prompt). fn run_agent_fzf_simple<'a>(entries: &'a [AgentEntry]) -> Result<Option<&'a AgentEntry>> { let mut child = Command::new("fzf") .arg("--prompt") .arg("copy agent> ") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for entry in entries { writeln!(stdin, "{}", entry.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selection = String::from_utf8(output.stdout) .context("fzf output was not valid UTF-8")? .trim() .to_string(); if selection.is_empty() { return Ok(None); } Ok(entries.iter().find(|entry| entry.display == selection)) } /// Get agent file content by name and optional path. fn get_agent_content(name: &str, path: Option<&Path>) -> Result<String> { // If path is provided, read directly if let Some(p) = path { return fs::read_to_string(p) .context(format!("failed to read agent file: {}", p.display())); } // Special case: flow agent has built-in instructions if name == FLOW_AGENT_NAME { return Ok(get_flow_agent_instructions()); } // Try to find agent in common locations let mut locations = vec![ dirs::home_dir().map(|h| h.join(".config/opencode/agent")), dirs::home_dir().map(|h| h.join(".opencode/agent")), ]; if let Some(repo) = gen_repo_from_env() { locations.push(Some(repo.join(".opencode/agent"))); } if let Some(repo) = default_gen_repo() { locations.push(Some(repo.join(".opencode/agent"))); } for loc in locations.into_iter().flatten() { let agent_path = loc.join(format!("{}.md", name)); if agent_path.exists() { return fs::read_to_string(&agent_path).context(format!( "failed to read agent file: {}", agent_path.display() )); } } bail!("Could not find agent file for '{}'", name) } /// Get built-in flow agent instructions. fn get_flow_agent_instructions() -> String { r#"You are a Flow-aware agent with full context about flow.toml, tasks, and the Flow CLI. ## Capabilities - Read and modify flow.toml configuration - Create, update, and run tasks - Understand the flow.toml schema and best practices - Help with CI/CD workflows, dependencies, and project setup ## Guidelines - Always read the existing flow.toml before making changes - Preserve existing configuration when adding new items - Use appropriate task names and descriptions - Follow TOML formatting conventions - Enforce Flow env store usage for secrets/tokens (use `f env get` / `f env run`) "# .to_string() } fn default_gen_repo() -> Option<PathBuf> { dirs::home_dir().map(|home| home.join(DEFAULT_GEN_REPO_REL)) } fn gen_repo_from_env() -> Option<PathBuf> { std::env::var("GEN_REPO").ok().map(PathBuf::from) } fn gen_repo_hint() -> String { default_gen_repo() .map(|path| path.display().to_string()) .unwrap_or_else(|| format!("~/{}", DEFAULT_GEN_REPO_REL)) } fn run_agent_fzf<'a>(entries: &'a [AgentEntry]) -> Result<Option<FzfAgentResult<'a>>> { let mut child = Command::new("fzf") .arg("--prompt") .arg("agents> ") .arg("--expect") .arg("tab") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for entry in entries { writeln!(stdin, "{}", entry.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let raw = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let mut lines = raw.lines(); let key = lines.next().unwrap_or(""); let with_args = key == "tab"; let selection = lines.next().unwrap_or("").trim(); if selection.is_empty() { return Ok(None); } let entry = entries.iter().find(|entry| entry.display == selection); Ok(entry.map(|e| FzfAgentResult { entry: e, with_args, })) } fn prompt_for_agent_prompt(agent_name: &str) -> Result<Vec<String>> { use std::io::{self, BufRead}; println!("(tip: use quotes for prompts with spaces, e.g. 'find all API endpoints')"); print!("f agents {} ", agent_name); io::stdout().flush()?; let stdin = io::stdin(); let line = stdin.lock().lines().next(); let input = match line { Some(Ok(s)) => s, _ => return Ok(Vec::new()), }; let args = shell_words::split(&input).context("failed to parse prompt")?; Ok(args) } fn build_agent_entries() -> Result<Vec<AgentEntry>> { let mut entries = Vec::new(); let mut seen = HashSet::new(); let flow_display = "[flow] flow - Flow-aware agent for flow.toml tasks and CLI"; seen.insert(flow_display.to_string()); entries.push(AgentEntry { name: FLOW_AGENT_NAME.to_string(), display: flow_display.to_string(), path: None, // flow agent is built-in, no file }); if let Ok(gen_entries) = fetch_gen_agent_entries() { for entry in gen_entries { if seen.insert(entry.display.clone()) { entries.push(entry); } } return Ok(entries); } if let Some(project_root) = find_project_root() { let opencode_dir = project_root.join(".opencode"); entries.extend(collect_agent_entries( &opencode_dir.join("agent"), "project", &mut seen, )?); entries.extend(collect_agent_entries( &opencode_dir.join("agents"), "project", &mut seen, )?); } if let Some(global_dir) = dirs::config_dir().map(|d| d.join("opencode")) { entries.extend(collect_agent_entries( &global_dir.join("agent"), "global-config", &mut seen, )?); entries.extend(collect_agent_entries( &global_dir.join("agents"), "global-config", &mut seen, )?); } Ok(entries) } fn find_project_root() -> Option<PathBuf> { let mut dir = std::env::current_dir().ok()?; loop { if dir.join(".opencode").exists() || dir.join("flow.toml").exists() { return Some(dir); } if !dir.pop() { break; } } None } fn apply_project_config_env(cmd: &mut Command) { if let Some(root) = find_project_root() { let opencode_dir = root.join(".opencode"); if opencode_dir.exists() { cmd.env("OPENCODE_CONFIG_DIR", opencode_dir); } } } fn collect_agent_entries( root: &Path, label: &str, seen: &mut HashSet<String>, ) -> Result<Vec<AgentEntry>> { let mut entries = Vec::new(); if !root.exists() { return Ok(entries); } let walker = WalkBuilder::new(root) .hidden(false) .git_ignore(false) .git_exclude(false) .ignore(false) .build(); for entry in walker { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let path = entry.path(); if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") { continue; } let name = match agent_name_from_path(root, path) { Some(n) => n, None => continue, }; let (desc, mode) = parse_agent_frontmatter(path)?; if matches!(mode.as_deref(), Some("primary")) { continue; } let summary = desc.unwrap_or_else(|| "No description".to_string()); let display = format!("[{label}] {} - {}", name, summary); if seen.insert(display.clone()) { entries.push(AgentEntry { name, display, path: Some(path.to_path_buf()), }); } } Ok(entries) } fn agent_name_from_path(root: &Path, path: &Path) -> Option<String> { let relative = path.strip_prefix(root).ok()?; let without_ext = relative.with_extension(""); Some( without_ext .to_string_lossy() .replace(std::path::MAIN_SEPARATOR, "/"), ) } fn parse_agent_frontmatter(path: &Path) -> Result<(Option<String>, Option<String>)> { let contents = fs::read_to_string(path).unwrap_or_default(); let mut lines = contents.lines(); if lines.next().map(|l| l.trim()) != Some("---") { return Ok((None, None)); } let mut desc: Option<String> = None; let mut mode: Option<String> = None; for line in lines { let line = line.trim(); if line == "---" { break; } if let Some(value) = line.strip_prefix("description:") { desc = Some(trim_yaml_scalar(value)); } else if let Some(value) = line.strip_prefix("mode:") { mode = Some(trim_yaml_scalar(value)); } } Ok((desc, mode)) } fn trim_yaml_scalar(value: &str) -> String { let trimmed = value.trim(); trimmed.trim_matches('"').trim_matches('\'').to_string() } /// Agents that operate on the current directory by default. const DIR_AGENTS: &[&str] = &["docker-to-flox"]; fn run_agent_optional(agent: &str, prompt: Option<Vec<String>>) -> Result<()> { let prompt_args = match prompt { Some(p) if !p.is_empty() => p, _ => { // For directory-based agents, use cwd as default if DIR_AGENTS.contains(&agent) { let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| ".".to_string()); vec![cwd] } else { prompt_for_agent_prompt(agent)? } } }; if prompt_args.is_empty() { bail!("No prompt provided."); } run_agent(agent, prompt_args) } fn list_gen_agents(gen_loc: &GenLocation) -> Result<()> { let status = match gen_loc { GenLocation::Binary(path) => Command::new(path) .args(["agent", "list"]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run gen agent list")?, GenLocation::Repo(repo) => { let mut cmd = Command::new("bun"); cmd.args([ "run", "--cwd", &repo.join("packages/opencode").to_string_lossy(), "--conditions=browser", "src/index.ts", "agent", "list", ]) .env("GEN_MODE", "1") .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); apply_project_config_env(&mut cmd); cmd.status().context("failed to run gen agent list")? } }; if status.success() { Ok(()) } else { bail!("gen agent list failed"); } } fn fetch_gen_agent_entries() -> Result<Vec<AgentEntry>> { let gen_loc = find_gen().ok_or_else(|| { anyhow::anyhow!( "gen not found. Install with:\n cd {} && f install\n # or set GEN_REPO env var", gen_repo_hint() ) })?; let output = match gen_loc { GenLocation::Binary(ref path) => Command::new(path) .args(["agent", "list"]) .output() .context("failed to run gen agent list")?, GenLocation::Repo(ref repo) => { let mut cmd = Command::new("bun"); cmd.args([ "run", "--cwd", &repo.join("packages/opencode").to_string_lossy(), "--conditions=browser", "src/index.ts", "agent", "list", ]) .env("GEN_MODE", "1"); apply_project_config_env(&mut cmd); cmd.output().context("failed to run gen agent list")? } }; if !output.status.success() { bail!( "gen agent list failed: {}", String::from_utf8_lossy(&output.stderr) ); } let stdout = String::from_utf8_lossy(&output.stdout); Ok(parse_gen_agent_list(&stdout)) } fn parse_gen_agent_list(stdout: &str) -> Vec<AgentEntry> { let mut entries = Vec::new(); for line in stdout.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some((name, mode)) = parse_agent_list_line(line) { if mode == "primary" { continue; } let display = format!("[gen] {} ({})", name, mode); entries.push(AgentEntry { name, display, path: None, // gen agents - path resolved separately }); } } entries } fn parse_agent_list_line(line: &str) -> Option<(String, String)> { if line.starts_with('{') || line.starts_with('"') || line.starts_with('[') || line.starts_with("}") { return None; } let end = line.strip_suffix(')')?; let (name, mode) = end.rsplit_once(" (")?; if name.trim().is_empty() { return None; } let mode = mode.trim(); if !matches!(mode, "subagent" | "all" | "primary") { return None; } Some((name.trim().to_string(), mode.to_string())) } /// Get the configured agent tool and model. fn get_agent_config() -> (String, Option<String>) { if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(agents) = flow.agents { let tool = agents.tool.unwrap_or_else(|| "gen".to_string()); return (tool, agents.model); } } } ("gen".to_string(), None) } /// Run an agent with a prompt. fn run_agent(agent: &str, prompt: Vec<String>) -> Result<()> { let prompt_str = prompt.join(" "); if prompt_str.is_empty() { bail!( "No prompt provided.\nUsage: f agents run {} \"your prompt here\"", agent ); } // Build the full prompt based on agent type let full_prompt = if agent == FLOW_AGENT_NAME { build_flow_prompt(&prompt_str)? } else { // Regular subagent - use Task tool format!( "Use the Task tool with subagent_type='{}' to: {}", agent, prompt_str ) }; println!("Invoking {} agent...\n", agent); let (tool, model) = get_agent_config(); let status = match tool.as_str() { "claude" => invoke_claude(&full_prompt)?, "opencode" => invoke_opencode(&full_prompt, model.as_deref())?, _ => { let gen_loc = find_gen().ok_or_else(|| { anyhow::anyhow!( "gen not found. Install with:\n cd {} && f install\n # or set GEN_REPO env var", gen_repo_hint() ) })?; invoke_gen(&gen_loc, &full_prompt)? } }; if !status.success() { bail!("Agent exited with status: {}", status); } Ok(()) } /// Invoke Claude Code with a prompt. fn invoke_claude(prompt: &str) -> Result<std::process::ExitStatus> { Command::new("claude") .args(["-p", prompt]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run claude") } /// Invoke opencode with a prompt and optional model. fn invoke_opencode(prompt: &str, model: Option<&str>) -> Result<std::process::ExitStatus> { let mut cmd = Command::new("opencode"); cmd.arg("run"); if let Some(m) = model { cmd.args(["--model", m]); } // Use default format (not json) for interactive output cmd.arg(prompt) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run opencode") } /// Run the flow agent and capture the final text output. pub fn run_flow_agent_capture(prompt: &str) -> Result<String> { let gen_loc = find_gen().ok_or_else(|| { anyhow::anyhow!( "gen not found. Install with:\n cd {} && f install\n # or set GEN_REPO env var", gen_repo_hint() ) })?; if prompt.trim().is_empty() { bail!("No prompt provided for flow agent."); } let full_prompt = build_flow_prompt(prompt)?; invoke_gen_capture(&gen_loc, &full_prompt) } /// Run the flow agent and stream text output while capturing the final response. pub fn run_flow_agent_capture_streaming(prompt: &str) -> Result<String> { let gen_loc = find_gen().ok_or_else(|| { anyhow::anyhow!( "gen not found. Install with:\n cd {} && f install\n # or set GEN_REPO env var", gen_repo_hint() ) })?; if prompt.trim().is_empty() { bail!("No prompt provided for flow agent."); } let full_prompt = build_flow_prompt(prompt)?; invoke_gen_capture_streaming(&gen_loc, &full_prompt) } /// Fallback model if not configured. const FALLBACK_AGENT_MODEL: &str = "openrouter/moonshotai/kimi-k2:free"; /// Get the agent model from config or use fallback. fn get_agent_model() -> String { if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(agents) = flow.agents { if let Some(model) = agents.model { return model; } } } } FALLBACK_AGENT_MODEL.to_string() } /// Invoke gen with a prompt and model. fn invoke_gen(location: &GenLocation, prompt: &str) -> Result<std::process::ExitStatus> { let model = get_agent_model(); invoke_gen_with_model(location, prompt, &model) } /// Invoke gen with a prompt and specific model. fn invoke_gen_with_model( location: &GenLocation, prompt: &str, model: &str, ) -> Result<std::process::ExitStatus> { match location { GenLocation::Binary(path) => Command::new(path) .args(["run", "--model", model, prompt]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run gen"), GenLocation::Repo(repo) => { let mut cmd = Command::new("bun"); cmd.args([ "run", "--cwd", &repo.join("packages/opencode").to_string_lossy(), "--conditions=browser", "src/index.ts", "run", "--model", model, prompt, ]) .env("GEN_MODE", "1") .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); apply_project_config_env(&mut cmd); cmd.status().context("failed to run gen from repo") } } } fn invoke_gen_capture(location: &GenLocation, prompt: &str) -> Result<String> { let output = match location { GenLocation::Binary(path) => Command::new(path) .args(["run", "--format", "json", prompt]) .stdin(Stdio::null()) .output() .context("failed to run gen"), GenLocation::Repo(repo) => { let mut cmd = Command::new("bun"); cmd.args([ "run", "--cwd", &repo.join("packages/opencode").to_string_lossy(), "--conditions=browser", "src/index.ts", "run", "--format", "json", prompt, ]) .env("GEN_MODE", "1") .stdin(Stdio::null()); apply_project_config_env(&mut cmd); cmd.output().context("failed to run gen from repo") } }?; if !output.status.success() { bail!("gen exited with status: {}", output.status); } let stdout = String::from_utf8_lossy(&output.stdout); if let Some(text) = extract_text_from_gen_output(&stdout) { return Ok(text); } let trimmed = stdout.trim(); if !trimmed.is_empty() { return Ok(trimmed.to_string()); } bail!("gen returned no output"); } fn invoke_gen_capture_streaming(location: &GenLocation, prompt: &str) -> Result<String> { let mut cmd = match location { GenLocation::Binary(path) => { let mut cmd = Command::new(path); cmd.args(["run", "--format", "json", prompt]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); cmd } GenLocation::Repo(repo) => { let mut cmd = Command::new("bun"); cmd.args([ "run", "--cwd", &repo.join("packages/opencode").to_string_lossy(), "--conditions=browser", "src/index.ts", "run", "--format", "json", prompt, ]) .env("GEN_MODE", "1") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); apply_project_config_env(&mut cmd); cmd } }; let mut child = cmd.spawn().context("failed to run gen")?; let stdout = child .stdout .take() .context("failed to capture gen stdout")?; let reader = BufReader::new(stdout); let mut last_text = String::new(); let mut final_text = String::new(); let mut printed_output = false; for line in reader.lines() { let line = line?; if let Some(text) = extract_text_from_gen_line(&line) { if text.starts_with(&last_text) { let delta = &text[last_text.len()..]; if !delta.is_empty() { print!("{delta}"); io::stdout().flush()?; printed_output = true; } final_text = text; } else { if !text.is_empty() { print!("{text}"); io::stdout().flush()?; printed_output = true; } if final_text.is_empty() { final_text = text; } else { final_text.push_str(&text); } } last_text = final_text.clone(); } } let status = child.wait().context("failed to wait for gen")?; if !status.success() { bail!("gen exited with status: {}", status); } if printed_output { if !final_text.ends_with('\n') { println!(); } } if final_text.trim().is_empty() { bail!("gen returned no output"); } Ok(final_text) } fn extract_text_from_gen_output(stdout: &str) -> Option<String> { let mut last_text: Option<String> = None; for line in stdout.lines() { if let Some(text) = extract_text_from_gen_line(line) { if !text.trim().is_empty() { last_text = Some(text.to_string()); } } } last_text } fn extract_text_from_gen_line(line: &str) -> Option<String> { let value: Value = serde_json::from_str(line).ok()?; let event_type = value.get("type").and_then(|t| t.as_str()); if event_type != Some("text") { return None; } value .get("part") .and_then(|part| part.get("text")) .and_then(|t| t.as_str()) .map(|text| text.to_string()) } /// Build a flow-aware prompt with full context. fn build_flow_prompt(user_prompt: &str) -> Result<String> { let mut context = String::new(); // Add flow.toml schema reference context.push_str(FLOW_SCHEMA_CONTEXT); // Add current project tasks if available let cwd = std::env::current_dir().unwrap_or_default(); if let Ok(discovery) = discover::discover_tasks(&cwd) { if !discovery.tasks.is_empty() { context.push_str("\n\n## Current Project Tasks\n\n"); for task in &discovery.tasks { let desc = task.task.description.as_deref().unwrap_or(""); if task.relative_dir.is_empty() { context.push_str(&format!( "- `{}`: {} ({})\n", task.task.name, desc, task.task.command )); } else { context.push_str(&format!( "- `{}` ({}): {} ({})\n", task.task.name, task.relative_dir, desc, task.task.command )); } } } } // Add CLI commands reference context.push_str(FLOW_CLI_CONTEXT); Ok(format!( "{}\n\n---\n\nUser request: {}\n\nComplete this task. Read flow.toml first if you need to modify it.", context, user_prompt )) } /// Flow.toml schema and best practices context. const FLOW_SCHEMA_CONTEXT: &str = r#"# Flow Task Runner Context You are a flow-aware agent. Flow is a task runner with these key concepts: ## flow.toml Schema ```toml version = 1 name = "project-name" # optional project identifier [flow] primary_task = "dev" # default task for `f` with no args [[tasks]] name = "task-name" # required: unique identifier command = "echo hello" # required: shell command to run description = "What it does" # optional: shown in task list shortcuts = ["t", "tn"] # optional: short aliases dependencies = ["other-task", "cargo"] # optional: run before, or ensure binary exists interactive = false # optional: needs TTY (auto-detected for sudo, vim, etc.) delegate_to_hub = false # optional: run via background hub daemon on_cancel = "cleanup cmd" # optional: run when Ctrl+C pressed output_file = "last-build-output.md" # optional: write task output to file # Dependencies section - define reusable deps [deps] cargo = "cargo" # simple binary check node = ["node", "npm"] # multiple binaries ripgrep = { pkg-path = "ripgrep" } # flox managed package # Flox integration for reproducible dependencies [flox.install] cargo.pkg-path = "cargo" nodejs.pkg-path = "nodejs" ``` ## Best Practices 1. **Task naming**: Use kebab-case (e.g., `deploy-prod`, `test-unit`) 2. **Shortcuts**: Add 1-3 char shortcuts for frequent tasks 3. **Descriptions**: Always add descriptions - they appear in `f tasks` and fuzzy search 4. **Dependencies**: List task deps (run first) or binary deps (check PATH) 5. **on_cancel**: Add cleanup for long-running tasks that spawn processes 6. **interactive**: Auto-detected for sudo/vim/ssh, set manually for custom TUIs 7. **output_file**: Capture full task output for debugging or sharing "#; /// Flow CLI commands context. const FLOW_CLI_CONTEXT: &str = r#" ## Flow CLI Commands - `f` - Fuzzy search tasks (fzf picker) - `f <task>` - Run task directly - `f tasks` - List all tasks - `f run <task> [args]` - Run task with args - `f init` - Create flow.toml scaffold - `f setup` - Bootstrap project or run setup task - `f commit` - AI-assisted git commit - `f agents run <type> "prompt"` - Run AI agent - `f agents global <agent>` - Run global agent - `f ps` - Show running tasks - `f kill` - Stop running tasks - `f logs <task>` - View task logs ## Task Arguments Tasks receive args as positional params: ```toml [[tasks]] name = "greet" command = "echo Hello $1" ``` Run: `f greet World` → prints "Hello World" "#; ================================================ FILE: src/ai.rs ================================================ //! AI session management for Claude Code, Codex, and Cursor integration. //! //! Tracks and manages AI coding sessions per project, allowing users to: //! - List sessions for the current project (Claude, Codex, or both) //! - Save/bookmark sessions with names //! - Resume sessions //! - Add notes to sessions //! - Copy session history to clipboard use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::env; use std::fs; use std::fs::OpenOptions; use std::hash::{Hash, Hasher}; use std::io::{self, BufRead, BufReader, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use regex::Regex; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; use serde_json::json; use toml::Value as TomlValue; use tracing::debug; use uuid::Uuid; use crate::activity_log; use crate::cli::{ AiAction, CodexDaemonAction, CodexMemoryAction, CodexRuntimeAction, CodexSkillEvalAction, CodexSkillSourceAction, CodexTelemetryAction, CodexTraceAction, ProviderAiAction, }; use crate::commit::configured_codex_bin_for_workdir; use crate::{ codex_memory, codex_telemetry, codex_text, codexd, config, project_snapshot, repo_capsule, url_inspect, }; use crate::{codex_runtime, codex_skill_eval}; use crate::env as flow_env; /// AI provider type #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Provider { Claude, Codex, Cursor, All, } /// Stored session metadata in .ai/sessions/<provider>/index.json #[derive(Debug, Serialize, Deserialize, Default)] struct SessionIndex { /// Map of user-friendly names to session metadata sessions: HashMap<String, SavedSession>, } #[derive(Debug, Serialize)] pub struct WebSession { pub id: String, pub provider: String, pub timestamp: Option<String>, pub name: Option<String>, pub messages: Vec<WebSessionMessage>, pub started_at: Option<String>, pub last_message_at: Option<String>, } #[derive(Debug, Serialize)] pub struct WebSessionMessage { pub role: String, pub content: String, } #[derive(Debug, Serialize)] pub struct SessionHistory { pub session_id: String, pub provider: String, pub started_at: Option<String>, pub last_message_at: Option<String>, pub messages: Vec<WebSessionMessage>, } struct SessionMessages { messages: Vec<WebSessionMessage>, started_at: Option<String>, last_message_at: Option<String>, } impl Default for SessionMessages { fn default() -> Self { Self { messages: Vec::new(), started_at: None, last_message_at: None, } } } /// Commit checkpoint stored in .ai/commit-checkpoints.json #[derive(Debug, Serialize, Deserialize, Default)] pub struct CommitCheckpoints { /// Last commit checkpoint pub last_commit: Option<CommitCheckpoint>, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CommitCheckpoint { /// When this checkpoint was created pub timestamp: String, /// Session ID that was active pub session_id: Option<String>, /// Timestamp of the last entry included in that commit pub last_entry_timestamp: Option<String>, } #[derive(Debug, Serialize, Deserialize, Clone)] struct SavedSession { /// Session ID (UUID) id: String, /// Which provider this session is from #[serde(default = "default_provider")] provider: String, /// Optional description description: Option<String>, /// When this session was saved saved_at: String, /// Last resumed timestamp last_resumed: Option<String>, } fn default_provider() -> String { "claude".to_string() } /// Session info extracted from session files #[derive(Debug, Clone)] struct AiSession { /// Session ID (UUID) session_id: String, /// Which provider (claude, codex, cursor) provider: Provider, /// First message timestamp timestamp: Option<String>, /// Last message timestamp last_message_at: Option<String>, /// Last user/assistant message text last_message: Option<String>, /// First user message (as summary) first_message: Option<String>, /// First error summary (for sessions that never produced a user message) error_summary: Option<String>, } /// Entry from a session .jsonl file (we only parse what we need) #[derive(Debug, Deserialize)] struct JsonlEntry { timestamp: Option<String>, message: Option<SessionMessage>, #[serde(rename = "type")] entry_type: Option<String>, subtype: Option<String>, level: Option<String>, error: Option<serde_json::Value>, } #[derive(Debug, Deserialize)] struct CodexEntry { timestamp: Option<String>, #[serde(rename = "type")] entry_type: Option<String>, payload: Option<serde_json::Value>, role: Option<String>, content: Option<serde_json::Value>, } #[derive(Debug, Deserialize)] struct CursorEntry { role: Option<String>, message: Option<SessionMessage>, } #[derive(Debug, Deserialize)] struct SessionMessage { role: Option<String>, content: Option<serde_json::Value>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct CodexRecoverRow { pub(crate) id: String, pub(crate) updated_at: i64, pub(crate) cwd: String, pub(crate) title: Option<String>, pub(crate) first_user_message: Option<String>, pub(crate) git_branch: Option<String>, #[serde(default)] pub(crate) model: Option<String>, #[serde(default)] pub(crate) reasoning_effort: Option<String>, } #[derive(Debug, Serialize)] struct CodexRecoverCandidate { id: String, updated_at: String, updated_at_unix: i64, cwd: String, git_branch: Option<String>, model: Option<String>, reasoning_effort: Option<String>, title: Option<String>, first_user_message: Option<String>, } #[derive(Debug, Serialize)] struct CodexRecoverOutput { target_path: String, exact_cwd: bool, query: Option<String>, recommended_route: String, summary: String, candidates: Vec<CodexRecoverCandidate>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct CodexResolvedReference { name: String, source: String, matched: String, command: Option<String>, output: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct CodexOpenPlan { action: String, route: String, reason: String, target_path: String, launch_path: String, query: Option<String>, session_id: Option<String>, prompt: Option<String>, references: Vec<CodexResolvedReference>, runtime_state_path: Option<String>, runtime_skills: Vec<String>, prompt_context_budget_chars: usize, max_resolved_references: usize, prompt_chars: usize, injected_context_chars: usize, trace: Option<CodexResolveWorkflowTrace>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveReferenceSnapshot { pub name: String, pub source: String, pub matched: String, pub command: Option<String>, pub output: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveRuntimeSkillSnapshot { pub name: String, pub kind: String, pub path: String, pub trigger: String, pub source: Option<String>, pub original_name: Option<String>, pub estimated_chars: Option<usize>, pub match_reason: Option<String>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveInspectorResponse { pub action: String, pub route: String, pub reason: String, pub target_path: String, pub launch_path: String, pub query: Option<String>, pub session_id: Option<String>, pub prompt: Option<String>, pub references: Vec<CodexResolveReferenceSnapshot>, pub runtime_state_path: Option<String>, pub runtime_skills: Vec<CodexResolveRuntimeSkillSnapshot>, pub prompt_context_budget_chars: usize, pub max_resolved_references: usize, pub prompt_chars: usize, pub injected_context_chars: usize, pub trace: Option<CodexResolveWorkflowTrace>, pub workflow: Option<CodexResolveWorkflowExplanation>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveWorkflowExplanation { pub id: String, pub title: String, pub summary: String, pub trigger: String, pub generated_by: String, pub packet: CodexResolveWorkflowPacket, pub commands: Vec<CodexResolveWorkflowCommand>, pub artifacts: Vec<CodexResolveWorkflowArtifact>, pub steps: Vec<CodexResolveWorkflowStep>, pub notes: Vec<String>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveWorkflowPacket { pub kind: String, pub compact_summary: String, pub default_view: String, pub expansion_rules: Vec<String>, pub validation_plan: Vec<CodexResolveWorkflowValidation>, #[serde(skip_serializing_if = "Option::is_none")] pub trace: Option<CodexResolveWorkflowTrace>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveWorkflowTrace { pub trace_id: String, pub span_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub parent_span_id: Option<String>, pub workflow_kind: String, pub service_name: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveWorkflowValidation { pub label: String, pub tier: String, pub detail: String, pub command: Option<String>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveWorkflowCommand { pub label: String, pub command: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveWorkflowArtifact { pub label: String, pub value: String, pub kind: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexResolveWorkflowStep { pub title: String, pub detail: String, } #[derive(Debug, Clone, PartialEq, Eq)] struct CodexSessionReferenceRequest { session_hints: Vec<String>, count: usize, user_request: String, } #[derive(Debug, Clone, PartialEq, Eq)] struct LinearUrlReference { url: String, workspace_slug: String, resource_kind: LinearUrlKind, resource_value: String, view: Option<String>, title_hint: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LinearUrlKind { Issue, Project, } const CODEX_QUERY_CACHE_VERSION: u32 = 1; const CODEX_QUERY_CACHE_ENV_DISABLE: &str = "FLOW_DISABLE_CODEX_QUERY_CACHE"; const CODEX_SESSION_COMPLETION_DEFAULT_SCAN_LIMIT: usize = 24; const CODEX_SESSION_COMPLETION_DEFAULT_IDLE_SECS: u64 = 90; const FLOW_CODEX_TRACE_SERVICE_NAME: &str = "flow_codex"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct CodexStateDbStamp { path: String, len: u64, modified_unix_secs: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexQueryCacheEntry { version: u32, stamp: CodexStateDbStamp, rows: Vec<CodexRecoverRow>, } #[derive(Debug, Clone, PartialEq, Eq)] struct CodexThreadSchema { has_model: bool, has_reasoning_effort: bool, } #[derive(Debug, Clone)] struct CodexThreadSchemaCacheEntry { stamp: CodexStateDbStamp, schema: CodexThreadSchema, } #[derive(Debug, Clone, PartialEq, Eq)] struct CodexSessionCompletionSnapshot { last_role: Option<String>, last_user_message: Option<String>, last_user_at_unix: Option<u64>, last_assistant_message: Option<String>, last_assistant_at_unix: Option<u64>, file_modified_unix: u64, } #[derive(Debug, Clone, PartialEq, Eq)] struct CodexTurnPatchChange { path: String, action: String, patch: String, } #[derive(Debug, Clone, PartialEq, Eq)] struct PrFeedbackCursorHandoff { workspace_path: PathBuf, review_plan_path: PathBuf, review_rules_path: Option<PathBuf>, kit_system_path: PathBuf, } /// Run a provider-specific action (for top-level `f codex` / `f claude` commands). pub fn run_provider(provider: Provider, action: Option<ProviderAiAction>) -> Result<()> { if provider == Provider::Cursor { match action { None | Some(ProviderAiAction::List) => list_sessions(Provider::Cursor)?, Some(ProviderAiAction::LatestId { path }) => { print_latest_session_id(Provider::Cursor, path)? } Some(ProviderAiAction::Connect { .. }) => { bail!("connect is only supported for Codex sessions; use `f codex connect ...`"); } Some(ProviderAiAction::Copy { session }) => copy_session(session, Provider::Cursor)?, Some(ProviderAiAction::Context { session, count, path, }) => copy_context(session, Provider::Cursor, count, path)?, Some(ProviderAiAction::Show { session, path, count, full, }) => show_session(session, Provider::Cursor, count, path, full)?, Some(ProviderAiAction::Runtime { .. }) => { bail!( "runtime helpers are only supported for Codex sessions; use `f codex runtime ...`" ); } Some(ProviderAiAction::Doctor { .. }) => { bail!("doctor is only supported for Codex sessions; use `f codex doctor`"); } Some(ProviderAiAction::Eval { .. }) => { bail!("eval is only supported for Codex sessions; use `f codex eval`"); } Some(ProviderAiAction::TouchLaunch { .. }) => { bail!( "touch-launch is only supported for Codex sessions; use `f codex touch-launch`" ); } Some(ProviderAiAction::EnableGlobal { .. }) => { bail!( "global Codex enablement is only supported for Codex sessions; use `f codex enable-global`" ); } Some(ProviderAiAction::Daemon { .. }) => { bail!("daemon is only supported for Codex sessions; use `f codex daemon ...`"); } Some(ProviderAiAction::Memory { .. }) => { bail!("memory is only supported for Codex sessions; use `f codex memory ...`"); } Some(ProviderAiAction::Telemetry { .. }) => { bail!("telemetry is only supported for Codex sessions; use `f codex telemetry ...`"); } Some(ProviderAiAction::Trace { .. }) => { bail!("trace is only supported for Codex sessions; use `f codex trace ...`"); } Some(ProviderAiAction::SkillEval { .. }) => { bail!( "skill-eval is only supported for Codex sessions; use `f codex skill-eval ...`" ); } Some(ProviderAiAction::SkillSource { .. }) => { bail!( "skill-source is only supported for Codex sessions; use `f codex skill-source ...`" ); } Some(ProviderAiAction::Sessions { .. }) | Some(ProviderAiAction::Continue { .. }) | Some(ProviderAiAction::New) | Some(ProviderAiAction::Open { .. }) | Some(ProviderAiAction::Resolve { .. }) | Some(ProviderAiAction::Resume { .. }) | Some(ProviderAiAction::Find { .. }) | Some(ProviderAiAction::FindAndCopy { .. }) => { bail!( "Cursor transcripts are readable only; use `f cursor list`, `f cursor copy`, or `f cursor context`" ); } Some(ProviderAiAction::Recover { .. }) => { bail!("recover is only supported for Codex sessions; use `f ai codex recover ...`"); } } return Ok(()); } match action { None => quick_start_session(provider)?, Some(ProviderAiAction::List) => list_sessions(provider)?, Some(ProviderAiAction::LatestId { path }) => print_latest_session_id(provider, path)?, Some(ProviderAiAction::Sessions { path, json }) => { provider_sessions(provider, path, json)? } Some(ProviderAiAction::Continue { session, path }) => { continue_session(session, path, provider)? } Some(ProviderAiAction::New) => new_session(provider)?, Some(ProviderAiAction::Connect { path, exact_cwd, json, query, }) => connect_codex_session(path, query, exact_cwd, json, provider)?, Some(ProviderAiAction::Open { path, exact_cwd, query, }) => open_codex_session(path, query, exact_cwd, provider)?, Some(ProviderAiAction::Daemon { action }) => codex_daemon_command(action, provider)?, Some(ProviderAiAction::Memory { action }) => codex_memory_command(action, provider)?, Some(ProviderAiAction::Telemetry { action }) => { codex_telemetry_command(action, provider)? } Some(ProviderAiAction::Trace { action }) => codex_trace_command(action, provider)?, Some(ProviderAiAction::SkillEval { action }) => codex_skill_eval_command(action, provider)?, Some(ProviderAiAction::SkillSource { action }) => { codex_skill_source_command(action, provider)? } Some(ProviderAiAction::Doctor { path, assert_runtime, assert_schedule, assert_learning, assert_autonomous, json, }) => codex_doctor( path, assert_runtime, assert_schedule, assert_learning, assert_autonomous, json, provider, )?, Some(ProviderAiAction::Eval { path, limit, json }) => { codex_eval(path, limit, json, provider)? } Some(ProviderAiAction::TouchLaunch { mode, cwd }) => { codex_touch_launch(mode, cwd, provider)? } Some(ProviderAiAction::EnableGlobal { dry_run, install_launchd, start_daemon, sync_skills, full, minutes, limit, max_targets, within_hours, }) => codex_enable_global( dry_run, install_launchd, start_daemon, sync_skills, full, minutes, limit, max_targets, within_hours, provider, )?, Some(ProviderAiAction::Resolve { path, exact_cwd, json, query, }) => resolve_codex_input(path, query, exact_cwd, json, provider)?, Some(ProviderAiAction::Runtime { action }) => codex_runtime_command(action, provider)?, Some(ProviderAiAction::Resume { session, path }) => { resume_session(session, path, provider)? } Some(ProviderAiAction::Find { path, exact_cwd, query, }) => find_codex_session(path, query, exact_cwd, provider)?, Some(ProviderAiAction::FindAndCopy { path, exact_cwd, query, }) => find_and_copy_codex_session(path, query, exact_cwd, provider)?, Some(ProviderAiAction::Copy { session }) => copy_session(session, provider)?, Some(ProviderAiAction::Context { session, count, path, }) => copy_context(session, provider, count, path)?, Some(ProviderAiAction::Show { session, path, count, full, }) => show_session(session, provider, count, path, full)?, Some(ProviderAiAction::Recover { path, exact_cwd, limit, json, summary_only, query, }) => recover_codex_sessions(path, query, exact_cwd, limit, json, summary_only, provider)?, } Ok(()) } /// Run the ai subcommand. pub fn run(action: Option<AiAction>) -> Result<()> { let action = action.unwrap_or(AiAction::List); match action { AiAction::List => list_sessions(Provider::All)?, AiAction::Cursor { action } => run_provider(Provider::Cursor, action)?, AiAction::Claude { action } => match action { None => quick_start_session(Provider::Claude)?, Some(ProviderAiAction::List) => list_sessions(Provider::Claude)?, Some(ProviderAiAction::LatestId { path }) => { print_latest_session_id(Provider::Claude, path)? } Some(ProviderAiAction::Sessions { path, json }) => { provider_sessions(Provider::Claude, path, json)? } Some(ProviderAiAction::Continue { session, path }) => { continue_session(session, path, Provider::Claude)? } Some(ProviderAiAction::New) => new_session(Provider::Claude)?, Some(ProviderAiAction::Connect { .. }) => { bail!("connect is only supported for Codex sessions; use `f codex connect ...`"); } Some(ProviderAiAction::Open { .. }) | Some(ProviderAiAction::Resolve { .. }) => { bail!("open/resolve is only supported for Codex sessions; use `f codex ...`"); } Some(ProviderAiAction::Runtime { .. }) => { bail!( "runtime helpers are only supported for Codex sessions; use `f codex runtime ...`" ); } Some(ProviderAiAction::Doctor { .. }) => { bail!("doctor is only supported for Codex sessions; use `f codex doctor`"); } Some(ProviderAiAction::Eval { .. }) => { bail!("eval is only supported for Codex sessions; use `f codex eval`"); } Some(ProviderAiAction::TouchLaunch { .. }) => { bail!( "touch-launch is only supported for Codex sessions; use `f codex touch-launch`" ); } Some(ProviderAiAction::EnableGlobal { .. }) => { bail!( "global Codex enablement is only supported for Codex sessions; use `f codex enable-global`" ); } Some(ProviderAiAction::Daemon { .. }) => { bail!("daemon is only supported for Codex sessions; use `f codex daemon ...`"); } Some(ProviderAiAction::Memory { .. }) => { bail!("memory is only supported for Codex sessions; use `f codex memory ...`"); } Some(ProviderAiAction::Telemetry { .. }) => { bail!("telemetry is only supported for Codex sessions; use `f codex telemetry ...`"); } Some(ProviderAiAction::Trace { .. }) => { bail!("trace is only supported for Codex sessions; use `f codex trace ...`"); } Some(ProviderAiAction::SkillEval { .. }) => { bail!( "skill-eval is only supported for Codex sessions; use `f codex skill-eval ...`" ); } Some(ProviderAiAction::SkillSource { .. }) => { bail!( "skill-source is only supported for Codex sessions; use `f codex skill-source ...`" ); } Some(ProviderAiAction::Resume { session, path }) => { resume_session(session, path, Provider::Claude)? } Some(ProviderAiAction::Find { path, exact_cwd, query, }) => find_codex_session(path, query, exact_cwd, Provider::Claude)?, Some(ProviderAiAction::FindAndCopy { path, exact_cwd, query, }) => find_and_copy_codex_session(path, query, exact_cwd, Provider::Claude)?, Some(ProviderAiAction::Copy { session }) => copy_session(session, Provider::Claude)?, Some(ProviderAiAction::Context { session, count, path, }) => copy_context(session, Provider::Claude, count, path)?, Some(ProviderAiAction::Show { session, path, count, full, }) => show_session(session, Provider::Claude, count, path, full)?, Some(ProviderAiAction::Recover { path, exact_cwd, limit, json, summary_only, query, }) => recover_codex_sessions( path, query, exact_cwd, limit, json, summary_only, Provider::Claude, )?, }, AiAction::Codex { action } => match action { None => quick_start_session(Provider::Codex)?, Some(ProviderAiAction::List) => list_sessions(Provider::Codex)?, Some(ProviderAiAction::LatestId { path }) => { print_latest_session_id(Provider::Codex, path)? } Some(ProviderAiAction::Sessions { path, json }) => { provider_sessions(Provider::Codex, path, json)? } Some(ProviderAiAction::Continue { session, path }) => { continue_session(session, path, Provider::Codex)? } Some(ProviderAiAction::New) => new_session(Provider::Codex)?, Some(ProviderAiAction::Connect { path, exact_cwd, json, query, }) => connect_codex_session(path, query, exact_cwd, json, Provider::Codex)?, Some(ProviderAiAction::Open { path, exact_cwd, query, }) => open_codex_session(path, query, exact_cwd, Provider::Codex)?, Some(ProviderAiAction::Daemon { action }) => { codex_daemon_command(action, Provider::Codex)? } Some(ProviderAiAction::Memory { action }) => { codex_memory_command(action, Provider::Codex)? } Some(ProviderAiAction::Telemetry { action }) => { codex_telemetry_command(action, Provider::Codex)? } Some(ProviderAiAction::Trace { action }) => { codex_trace_command(action, Provider::Codex)? } Some(ProviderAiAction::SkillEval { action }) => { codex_skill_eval_command(action, Provider::Codex)? } Some(ProviderAiAction::SkillSource { action }) => { codex_skill_source_command(action, Provider::Codex)? } Some(ProviderAiAction::Doctor { path, assert_runtime, assert_schedule, assert_learning, assert_autonomous, json, }) => codex_doctor( path, assert_runtime, assert_schedule, assert_learning, assert_autonomous, json, Provider::Codex, )?, Some(ProviderAiAction::Eval { path, limit, json }) => { codex_eval(path, limit, json, Provider::Codex)? } Some(ProviderAiAction::TouchLaunch { mode, cwd }) => { codex_touch_launch(mode, cwd, Provider::Codex)? } Some(ProviderAiAction::EnableGlobal { dry_run, install_launchd, start_daemon, sync_skills, full, minutes, limit, max_targets, within_hours, }) => codex_enable_global( dry_run, install_launchd, start_daemon, sync_skills, full, minutes, limit, max_targets, within_hours, Provider::Codex, )?, Some(ProviderAiAction::Resolve { path, exact_cwd, json, query, }) => resolve_codex_input(path, query, exact_cwd, json, Provider::Codex)?, Some(ProviderAiAction::Runtime { action }) => { codex_runtime_command(action, Provider::Codex)? } Some(ProviderAiAction::Resume { session, path }) => { resume_session(session, path, Provider::Codex)? } Some(ProviderAiAction::Find { path, exact_cwd, query, }) => find_codex_session(path, query, exact_cwd, Provider::Codex)?, Some(ProviderAiAction::FindAndCopy { path, exact_cwd, query, }) => find_and_copy_codex_session(path, query, exact_cwd, Provider::Codex)?, Some(ProviderAiAction::Copy { session }) => copy_session(session, Provider::Codex)?, Some(ProviderAiAction::Context { session, count, path, }) => copy_context(session, Provider::Codex, count, path)?, Some(ProviderAiAction::Show { session, path, count, full, }) => show_session(session, Provider::Codex, count, path, full)?, Some(ProviderAiAction::Recover { path, exact_cwd, limit, json, summary_only, query, }) => recover_codex_sessions( path, query, exact_cwd, limit, json, summary_only, Provider::Codex, )?, }, AiAction::Everruns(opts) => crate::ai_everruns::run(opts)?, AiAction::Resume { session, path } => resume_session(session, path, Provider::All)?, AiAction::Save { name, id } => save_session(&name, id)?, AiAction::Notes { session } => open_notes(&session)?, AiAction::Remove { session } => remove_session(&session)?, AiAction::Init => init_ai_folder()?, AiAction::Import => import_sessions()?, AiAction::Copy { session } => copy_session(session, Provider::All)?, AiAction::CopyClaude { search } => { let query = if search.is_empty() { None } else { Some(search.join(" ")) }; copy_last_session(Provider::Claude, query)? } AiAction::CopyCodex { search } => { let query = if search.is_empty() { None } else { Some(search.join(" ")) }; copy_last_session(Provider::Codex, query)? } AiAction::Context { session, count, path, } => copy_context(session, Provider::All, count, path)?, } Ok(()) } fn for_each_nonempty_jsonl_line(path: &Path, mut on_line: impl FnMut(&str)) -> Result<()> { let file = fs::File::open(path).with_context(|| format!("failed to read {}", path.display()))?; let mut reader = BufReader::with_capacity(64 * 1024, file); let mut line = String::with_capacity(1024); loop { line.clear(); if reader.read_line(&mut line)? == 0 { break; } let line = line.trim_end_matches(['\n', '\r']); if line.trim().is_empty() { continue; } on_line(line); } Ok(()) } /// Get checkpoint file path for a project. fn get_checkpoint_path(project_path: &PathBuf) -> PathBuf { project_path .join(".ai") .join("internal") .join("commit-checkpoints.json") } /// Load commit checkpoints. pub fn load_checkpoints(project_path: &PathBuf) -> Result<CommitCheckpoints> { let path = get_checkpoint_path(project_path); if !path.exists() { return Ok(CommitCheckpoints::default()); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; serde_json::from_str(&content).context("failed to parse commit-checkpoints.json") } /// Save commit checkpoints. pub fn save_checkpoint(project_path: &PathBuf, checkpoint: CommitCheckpoint) -> Result<()> { let path = get_checkpoint_path(project_path); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let checkpoints = CommitCheckpoints { last_commit: Some(checkpoint), }; let content = serde_json::to_string_pretty(&checkpoints)?; fs::write(&path, content)?; Ok(()) } /// Log review result for tracking async commits. pub fn log_review_result( project_path: &PathBuf, issues_found: bool, issues: &[String], context_chars: usize, review_time_secs: u64, ) { let log_path = project_path .join(".ai") .join("internal") .join("review-log.jsonl"); if let Some(parent) = log_path.parent() { let _ = fs::create_dir_all(parent); } let entry = json!({ "timestamp": chrono::Utc::now().to_rfc3339(), "issues_found": issues_found, "issue_count": issues.len(), "context_chars": context_chars, "review_time_secs": review_time_secs, }); if let Ok(mut file) = fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) { let _ = writeln!(file, "{}", entry); } } /// Log commit review details for later analysis. pub fn log_commit_review( project_path: &PathBuf, commit_sha: &str, branch: &str, message: &str, review_model: &str, reviewer: &str, issues_found: bool, issues: &[String], summary: Option<&str>, timed_out: bool, context_chars: usize, ) { let log_dir = project_path.join(".ai").join("internal").join("commits"); let log_path = log_dir.join("review-log.jsonl"); if let Some(parent) = log_path.parent() { let _ = fs::create_dir_all(parent); } let entry = json!({ "timestamp": chrono::Utc::now().to_rfc3339(), "commit_sha": commit_sha, "branch": branch, "message": message, "review": { "model": review_model, "reviewer": reviewer, "issues_found": issues_found, "issue_count": issues.len(), "issues": issues, "summary": summary, "timed_out": timed_out, }, "context_chars": context_chars, }); if let Ok(mut file) = fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) { let _ = writeln!(file, "{}", entry); } } #[derive(Debug, Serialize)] pub struct CommitReviewSummary { pub model: String, pub reviewer: String, pub issues_found: bool, pub issues: Vec<String>, pub summary: Option<String>, pub timed_out: bool, } /// Log commit metadata (with optional review data) for later analysis. pub fn log_commit_event( project_path: &PathBuf, commit_sha: &str, branch: &str, message: &str, author_name: &str, author_email: &str, command: &str, review: Option<CommitReviewSummary>, context_chars: Option<usize>, ) { let log_dir = project_path.join(".ai").join("internal").join("commits"); let log_path = log_dir.join("log.jsonl"); if let Some(parent) = log_path.parent() { let _ = fs::create_dir_all(parent); } let entry = json!({ "timestamp": chrono::Utc::now().to_rfc3339(), "commit_sha": commit_sha, "branch": branch, "message": message, "author": { "name": author_name, "email": author_email, }, "command": command, "review": review, "context_chars": context_chars, }); if let Ok(mut file) = fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) { let _ = writeln!(file, "{}", entry); } } /// Get AI session context since the last commit checkpoint. /// Returns all exchanges from the checkpoint timestamp to now. pub fn get_context_since_checkpoint() -> Result<Option<String>> { let cwd = std::env::current_dir().context("failed to get current directory")?; get_context_since_checkpoint_for_path(&cwd) } /// Get AI session context since the last commit checkpoint for a specific path. pub fn get_context_since_checkpoint_for_path(project_path: &PathBuf) -> Result<Option<String>> { let checkpoints = load_checkpoints(project_path).unwrap_or_default(); // Get sessions for Claude, Codex, and Cursor let sessions = read_sessions_for_path(Provider::All, project_path)?; if sessions.is_empty() { return Ok(None); } // Read context since checkpoint let since_ts = checkpoints .last_commit .as_ref() .and_then(|c| c.last_entry_timestamp.clone()); let mut combined = String::new(); let since_info = if since_ts.is_some() { " (since last commit)" } else { " (full session - no previous commit)" }; for session in sessions { let provider_name = match session.provider { Provider::Claude => "Claude Code", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; if let Ok((context, last_ts)) = read_context_since( &session.session_id, session.provider, since_ts.as_deref(), project_path, ) { if context.trim().is_empty() { continue; } if !combined.is_empty() { combined.push_str("\n\n"); } combined.push_str(&format!( "=== {} Session Context{} ===\nLast entry: {}\n\n{}\n\n=== End Session Context ===", provider_name, since_info, last_ts.unwrap_or_else(|| "unknown".to_string()), context )); } } if combined.trim().is_empty() { Ok(None) } else { Ok(Some(combined)) } } /// Structured AI session data for GitEdit sync. #[derive(Debug, Serialize, Clone)] pub struct GitEditSessionData { pub session_id: String, pub provider: String, pub started_at: Option<String>, pub last_activity_at: Option<String>, pub exchanges: Vec<GitEditExchange>, pub context_summary: Option<String>, } #[derive(Debug, Serialize, Clone)] pub struct GitEditExchange { pub user_message: String, pub assistant_message: String, pub timestamp: String, } /// Get session IDs quickly for early hash generation. /// Returns (session_ids, checkpoint_timestamp) for hashing before full data load. pub fn get_session_ids_for_hash(project_path: &PathBuf) -> Result<(Vec<String>, Option<String>)> { let checkpoints = load_checkpoints(project_path).unwrap_or_default(); let sessions = read_sessions_for_path(Provider::All, project_path)?; let checkpoint_ts = checkpoints .last_commit .as_ref() .and_then(|c| c.last_entry_timestamp.clone()); let session_ids: Vec<String> = sessions.iter().map(|s| s.session_id.clone()).collect(); Ok((session_ids, checkpoint_ts)) } /// Get structured AI session data for GitEdit sync. /// Returns sessions with full exchange history since the last checkpoint. pub fn get_sessions_for_gitedit(project_path: &PathBuf) -> Result<Vec<GitEditSessionData>> { let checkpoints = load_checkpoints(project_path).unwrap_or_default(); let since_ts = checkpoints .last_commit .as_ref() .and_then(|c| c.last_entry_timestamp.clone()); get_sessions_for_gitedit_between(project_path, since_ts.as_deref(), None) } /// Get structured AI session data for GitEdit/myflow sync in a strict time window. /// Includes exchanges where `since_ts < exchange_ts <= until_ts` (when bounds are provided). pub fn get_sessions_for_gitedit_between( project_path: &PathBuf, since_ts: Option<&str>, until_ts: Option<&str>, ) -> Result<Vec<GitEditSessionData>> { let sessions = read_sessions_for_path(Provider::All, project_path)?; if sessions.is_empty() { return Ok(vec![]); } let mut result = Vec::new(); for session in sessions { let provider_name = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "unknown", }; // Get full exchanges (not summarized) let exchanges = get_session_exchanges_since( &session.session_id, session.provider, since_ts, until_ts, project_path, )?; if exchanges.is_empty() { continue; } // Get last timestamp from exchanges let last_activity = exchanges.last().map(|e| e.timestamp.clone()); // Create context summary (first few words of first user message) let context_summary = exchanges.first().map(|e| { let msg = &e.user_message; let words: Vec<&str> = msg.split_whitespace().take(10).collect(); let summary = words.join(" "); if msg.split_whitespace().count() > 10 { format!("{}...", summary) } else { summary } }); result.push(GitEditSessionData { session_id: session.session_id.clone(), provider: provider_name.to_string(), started_at: session.timestamp.clone(), last_activity_at: last_activity, exchanges, context_summary, }); } Ok(result) } /// Get full exchanges from a session since a timestamp. fn get_session_exchanges_since( session_id: &str, provider: Provider, since_ts: Option<&str>, until_ts: Option<&str>, project_path: &PathBuf, ) -> Result<Vec<GitEditExchange>> { if provider == Provider::Codex { let session_file = find_codex_session_file(session_id); if let Some(session_file) = session_file { let (exchanges, _) = read_codex_exchanges(&session_file, since_ts, until_ts)?; return Ok(exchanges .into_iter() .map(|(user, assistant, ts)| GitEditExchange { user_message: user, assistant_message: assistant, timestamp: ts, }) .collect()); } return Ok(vec![]); } if provider == Provider::Cursor { let session_file = find_cursor_session_file(session_id); if let Some(session_file) = session_file { let (exchanges, _) = read_cursor_exchanges(&session_file, since_ts, until_ts)?; return Ok(exchanges .into_iter() .map(|(user, assistant, ts)| GitEditExchange { user_message: user, assistant_message: assistant, timestamp: ts, }) .collect()); } return Ok(vec![]); } let path_str = project_path.to_string_lossy().to_string(); let project_folder = path_to_project_name(&path_str); let projects_dir = get_claude_projects_dir(); let session_file = projects_dir .join(&project_folder) .join(format!("{}.jsonl", session_id)); if !session_file.exists() { return Ok(vec![]); } let window = parse_timestamp_window(since_ts, until_ts); let mut exchanges: Vec<GitEditExchange> = Vec::new(); let mut current_user: Option<String> = None; let mut current_ts: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { if let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) { let entry_ts = entry.timestamp.clone(); // In bounded mode, require a timestamp and enforce window. if since_ts.is_some() || until_ts.is_some() { let Some(ref ts) = entry_ts else { return; }; if !timestamp_in_window_cached(ts, &window) { return; } } if let Some(ref msg) = entry.message { let role = msg.role.as_deref().unwrap_or("unknown"); let Some(content_text) = msg.content.as_ref().and_then(extract_message_text) else { return; }; let Some(clean_text) = normalize_session_message(role, &content_text) else { return; }; match role { "user" => { current_user = Some(clean_text); current_ts = entry_ts.clone(); } "assistant" => { if let Some(user_msg) = current_user.take() { let ts = current_ts.take().or(entry_ts).unwrap_or_default(); exchanges.push(GitEditExchange { user_message: user_msg, assistant_message: clean_text, timestamp: ts, }); } } _ => {} } } } })?; Ok(exchanges) } /// Get the last entry timestamp from the current session (for saving checkpoint). pub fn get_last_entry_timestamp() -> Result<Option<(String, String)>> { let cwd = std::env::current_dir().context("failed to get current directory")?; get_last_entry_timestamp_for_path(&cwd) } /// Get the last entry timestamp for sessions associated with a specific path. pub fn get_last_entry_timestamp_for_path( project_path: &PathBuf, ) -> Result<Option<(String, String)>> { let sessions = read_sessions_for_path(Provider::All, project_path)?; if sessions.is_empty() { return Ok(None); } let mut best: Option<(String, String)> = None; for session in sessions { if let Some(ts) = get_session_last_timestamp(&session.session_id, session.provider, project_path)? { let is_newer = best.as_ref().map_or(true, |(_, best_ts)| ts > *best_ts); if is_newer { best = Some((session.session_id.clone(), ts)); } } } Ok(best) } /// Get the last timestamp from a session file. fn get_session_last_timestamp( session_id: &str, provider: Provider, project_path: &PathBuf, ) -> Result<Option<String>> { if provider == Provider::Codex { let session_file = find_codex_session_file(session_id); let Some(session_file) = session_file else { return Ok(None); }; return get_codex_last_timestamp(&session_file); } if provider == Provider::Cursor { let session_file = find_cursor_session_file(session_id); let Some(session_file) = session_file else { return Ok(None); }; return get_cursor_last_timestamp(&session_file); } let path_str = project_path.to_string_lossy().to_string(); let project_folder = path_to_project_name(&path_str); let projects_dir = match provider { Provider::Claude | Provider::All => get_claude_projects_dir(), Provider::Codex => get_codex_projects_dir(), Provider::Cursor => get_cursor_projects_dir(), }; let session_file = projects_dir .join(&project_folder) .join(format!("{}.jsonl", session_id)); if !session_file.exists() { return Ok(None); } let mut last_ts: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { if let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) { if let Some(ts) = entry.timestamp { last_ts = Some(ts); } } })?; Ok(last_ts) } /// Read context from session since a given timestamp. fn read_context_since( session_id: &str, provider: Provider, since_ts: Option<&str>, project_path: &PathBuf, ) -> Result<(String, Option<String>)> { if provider == Provider::Codex { let session_file = find_codex_session_file(session_id).ok_or_else(|| { anyhow::anyhow!("Session file not found for Codex session {}", session_id) })?; return read_codex_context_since(&session_file, since_ts); } if provider == Provider::Cursor { let session_file = find_cursor_session_file(session_id).ok_or_else(|| { anyhow::anyhow!("Session file not found for Cursor session {}", session_id) })?; let (exchanges, last_ts) = read_cursor_exchanges(&session_file, since_ts, None)?; if exchanges.is_empty() { return Ok((String::new(), last_ts)); } const MAX_EXCHANGES: usize = 5; const MAX_USER_CHARS: usize = 500; const MAX_ASSIST_CHARS: usize = 300; let total_exchanges = exchanges.len(); let exchanges_to_use: Vec<_> = if total_exchanges > MAX_EXCHANGES { exchanges .into_iter() .skip(total_exchanges - MAX_EXCHANGES) .collect() } else { exchanges }; let mut context = String::new(); if total_exchanges > MAX_EXCHANGES { context.push_str(&format!("[+{} earlier]\n", total_exchanges - MAX_EXCHANGES)); } for (user_msg, assistant_msg, _ts) in &exchanges_to_use { let user_intent = extract_intent(user_msg, MAX_USER_CHARS); let assist_summary = extract_intent(assistant_msg, MAX_ASSIST_CHARS); context.push_str(">"); context.push_str(&user_intent); context.push('\n'); context.push_str(&assist_summary); context.push_str("\n\n"); } return Ok((context.trim().to_string(), last_ts)); } let path_str = project_path.to_string_lossy().to_string(); let project_folder = path_to_project_name(&path_str); let projects_dir = match provider { Provider::Claude | Provider::All => get_claude_projects_dir(), Provider::Codex => get_codex_projects_dir(), Provider::Cursor => get_cursor_projects_dir(), }; let session_file = projects_dir .join(&project_folder) .join(format!("{}.jsonl", session_id)); if !session_file.exists() { bail!("Session file not found: {}", session_file.display()); } // Collect exchanges after the checkpoint timestamp let mut exchanges: Vec<(String, String, String)> = Vec::new(); // (user_msg, assistant_msg, timestamp) let mut current_user: Option<String> = None; let mut current_ts: Option<String> = None; let mut last_ts: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { if let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) { let entry_ts = entry.timestamp.clone(); // Skip entries before checkpoint if let (Some(since), Some(ts)) = (since_ts, &entry_ts) { if ts.as_str() <= since { return; } } if let Some(ref msg) = entry.message { let role = msg.role.as_deref().unwrap_or("unknown"); let Some(content_text) = msg.content.as_ref().and_then(extract_message_text) else { return; }; let Some(clean_text) = normalize_session_message(role, &content_text) else { return; }; match role { "user" => { current_user = Some(clean_text); current_ts = entry_ts.clone(); } "assistant" => { if let Some(user_msg) = current_user.take() { let ts = current_ts.take().or(entry_ts.clone()).unwrap_or_default(); exchanges.push((user_msg, clean_text, ts.clone())); last_ts = Some(ts); } } _ => {} } } if entry_ts.is_some() { last_ts = entry_ts; } } })?; if exchanges.is_empty() { return Ok((String::new(), last_ts)); } // Optimization: prioritize recent exchanges, fit within reasonable budget // Keep it compact - extract intent, not full conversation const MAX_EXCHANGES: usize = 5; const MAX_USER_CHARS: usize = 500; // User requests are short const MAX_ASSIST_CHARS: usize = 300; // Just capture what was done, not full response let total_exchanges = exchanges.len(); let exchanges_to_use: Vec<_> = if total_exchanges > MAX_EXCHANGES { exchanges .into_iter() .skip(total_exchanges - MAX_EXCHANGES) .collect() } else { exchanges }; // Format compact context - focus on intent let mut context = String::new(); if total_exchanges > MAX_EXCHANGES { context.push_str(&format!("[+{} earlier]\n", total_exchanges - MAX_EXCHANGES)); } for (user_msg, assistant_msg, _ts) in &exchanges_to_use { // Extract first line/sentence of user msg as intent let user_intent = extract_intent(user_msg, MAX_USER_CHARS); let assist_summary = extract_intent(assistant_msg, MAX_ASSIST_CHARS); context.push_str(">"); context.push_str(&user_intent); context.push('\n'); context.push_str(&assist_summary); context.push_str("\n\n"); } context = context.trim().to_string(); Ok((context, last_ts)) } /// Find the largest valid UTF-8 char boundary at or before `pos`. fn floor_char_boundary(s: &str, pos: usize) -> usize { let mut end = pos.min(s.len()); while end > 0 && !s.is_char_boundary(end) { end -= 1; } end } /// Truncate a message to max chars, preserving meaningful content fn truncate_message(msg: &str, max_chars: usize) -> String { if msg.len() <= max_chars { return msg.to_string(); } let end = floor_char_boundary(msg, max_chars); format!("{}...", &msg[..end]) } /// Extract intent from a message - first meaningful content, truncated fn extract_intent(msg: &str, max_chars: usize) -> String { // Skip common prefixes and get to the meat let clean = msg .trim() .trim_start_matches("I'll ") .trim_start_matches("I will ") .trim_start_matches("Let me ") .trim_start_matches("Sure, ") .trim_start_matches("Okay, ") .trim_start_matches("I'm going to ") .trim(); // Take first line or sentence let first_part = clean .lines() .next() .unwrap_or(clean) .split(". ") .next() .unwrap_or(clean); truncate_message(first_part, max_chars) } fn read_codex_context_since( session_file: &PathBuf, since_ts: Option<&str>, ) -> Result<(String, Option<String>)> { let (exchanges, last_ts) = read_codex_exchanges(session_file, since_ts, None)?; if exchanges.is_empty() { return Ok((String::new(), last_ts)); } // Optimization: only keep last N exchanges for efficiency const MAX_EXCHANGES: usize = 8; const MAX_MSG_CHARS: usize = 2000; let total_exchanges = exchanges.len(); let exchanges_to_use: Vec<_> = if total_exchanges > MAX_EXCHANGES { exchanges .into_iter() .skip(total_exchanges - MAX_EXCHANGES) .collect() } else { exchanges }; let mut context = String::new(); // Add summary if we skipped older exchanges if total_exchanges > MAX_EXCHANGES { context.push_str(&format!( "[{} earlier exchanges omitted for brevity]\n\n", total_exchanges - MAX_EXCHANGES )); } for (user_msg, assistant_msg, _ts) in &exchanges_to_use { context.push_str("H: "); context.push_str(&truncate_message(user_msg, MAX_MSG_CHARS)); context.push_str("\n\n"); context.push_str("A: "); context.push_str(&truncate_message(assistant_msg, MAX_MSG_CHARS)); context.push_str("\n\n"); } while context.ends_with('\n') { context.pop(); } context.push('\n'); Ok((context, last_ts)) } fn read_codex_last_context(session_file: &PathBuf, count: usize) -> Result<String> { let (exchanges, _last_ts) = read_codex_exchanges(session_file, None, None)?; if exchanges.is_empty() { bail!("No exchanges found in session"); } let start = exchanges.len().saturating_sub(count); let last_exchanges = &exchanges[start..]; let mut context = String::new(); for (user_msg, assistant_msg, _ts) in last_exchanges { context.push_str("Human: "); context.push_str(user_msg); context.push_str("\n\n"); context.push_str("Assistant: "); context.push_str(assistant_msg); context.push_str("\n\n"); } while context.ends_with('\n') { context.pop(); } context.push('\n'); Ok(context) } pub(crate) fn read_codex_memory_exchanges( session_id: &str, max_count: usize, ) -> Result<Vec<(String, String)>> { let session_file = find_codex_session_file(session_id) .ok_or_else(|| anyhow::anyhow!("Codex session file not found: {}", session_id))?; let (exchanges, _last_ts) = read_codex_exchanges(&session_file, None, None)?; if exchanges.is_empty() || max_count == 0 { return Ok(Vec::new()); } let start = exchanges.len().saturating_sub(max_count); Ok(exchanges[start..] .iter() .filter_map(|(user, assistant, _)| { let user = normalize_session_message("user", user)?; let assistant = normalize_session_message("assistant", assistant)?; Some((user, assistant)) }) .collect()) } fn read_cursor_last_context(session_file: &PathBuf, count: usize) -> Result<String> { let (exchanges, _last_ts) = read_cursor_exchanges(session_file, None, None)?; if exchanges.is_empty() { bail!("No exchanges found in session"); } let start = exchanges.len().saturating_sub(count); let last_exchanges = &exchanges[start..]; let mut context = String::new(); for (user_msg, assistant_msg, _ts) in last_exchanges { context.push_str("Human: "); context.push_str(user_msg); context.push_str("\n\n"); context.push_str("Assistant: "); context.push_str(assistant_msg); context.push_str("\n\n"); } while context.ends_with('\n') { context.pop(); } context.push('\n'); Ok(context) } fn read_codex_exchanges( session_file: &PathBuf, since_ts: Option<&str>, until_ts: Option<&str>, ) -> Result<(Vec<(String, String, String)>, Option<String>)> { let window = parse_timestamp_window(since_ts, until_ts); let mut exchanges: Vec<(String, String, String)> = Vec::new(); let mut current_user: Option<String> = None; let mut current_ts: Option<String> = None; let mut last_ts: Option<String> = None; for_each_nonempty_jsonl_line(session_file, |line| { let entry: CodexEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; let entry_ts = entry.timestamp.clone(); if since_ts.is_some() || until_ts.is_some() { let Some(ts) = entry_ts.as_deref() else { return; }; if !timestamp_in_window_cached(ts, &window) { return; } } if let Some((role, text)) = extract_codex_message(&entry) { match role.as_str() { "user" => { current_user = Some(text); current_ts = entry_ts.clone(); } "assistant" => { if let Some(user_msg) = current_user.take() { let ts = current_ts.take().or(entry_ts.clone()).unwrap_or_default(); exchanges.push((user_msg, text, ts.clone())); last_ts = Some(ts); } } _ => {} } } if entry_ts.is_some() { last_ts = entry_ts; } })?; Ok((exchanges, last_ts)) } fn read_cursor_exchanges( session_file: &PathBuf, since_ts: Option<&str>, until_ts: Option<&str>, ) -> Result<(Vec<(String, String, String)>, Option<String>)> { let session_ts = get_cursor_last_timestamp(session_file)?; if since_ts.is_some() || until_ts.is_some() { let window = parse_timestamp_window(since_ts, until_ts); if session_ts .as_deref() .map(|ts| !timestamp_in_window_cached(ts, &window)) .unwrap_or(false) { return Ok((Vec::new(), session_ts)); } } let mut exchanges: Vec<(String, String, String)> = Vec::new(); let mut current_user: Option<String> = None; for_each_nonempty_jsonl_line(session_file, |line| { let entry: CursorEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; let Some((role, text)) = extract_cursor_message(&entry) else { return; }; match role.as_str() { "user" => { current_user = Some(text); } "assistant" => { if let Some(user_msg) = current_user.take() { let ts = session_ts.clone().unwrap_or_default(); exchanges.push((user_msg, text, ts)); } } _ => {} } })?; Ok((exchanges, session_ts)) } fn parse_timestamp_for_compare(ts: &str) -> Option<chrono::DateTime<chrono::Utc>> { chrono::DateTime::parse_from_rfc3339(ts) .map(|dt| dt.with_timezone(&chrono::Utc)) .or_else(|_| { chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.fZ") .map(|dt| dt.and_utc()) }) .ok() } struct TimestampWindow<'a> { since_raw: Option<&'a str>, until_raw: Option<&'a str>, since_dt: Option<chrono::DateTime<chrono::Utc>>, until_dt: Option<chrono::DateTime<chrono::Utc>>, } fn parse_timestamp_window<'a>( since_ts: Option<&'a str>, until_ts: Option<&'a str>, ) -> TimestampWindow<'a> { TimestampWindow { since_raw: since_ts, until_raw: until_ts, since_dt: since_ts.and_then(parse_timestamp_for_compare), until_dt: until_ts.and_then(parse_timestamp_for_compare), } } fn timestamp_in_window_cached(ts: &str, window: &TimestampWindow<'_>) -> bool { let ts_dt = parse_timestamp_for_compare(ts); if let Some(entry_dt) = ts_dt { if let Some(lower) = window.since_dt { if entry_dt <= lower { return false; } } else if let Some(lower_raw) = window.since_raw { if ts <= lower_raw { return false; } } if let Some(upper) = window.until_dt { if entry_dt > upper { return false; } } else if let Some(upper_raw) = window.until_raw { if ts > upper_raw { return false; } } return true; } if let Some(lower_raw) = window.since_raw { if ts <= lower_raw { return false; } } if let Some(upper_raw) = window.until_raw { if ts > upper_raw { return false; } } true } fn get_codex_last_timestamp(session_file: &PathBuf) -> Result<Option<String>> { let mut last_ts: Option<String> = None; for_each_nonempty_jsonl_line(session_file, |line| { let entry: CodexEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; if let Some(ts) = entry.timestamp { last_ts = Some(ts); return; } if let Some(payload_ts) = entry .payload .as_ref() .and_then(|p| p.get("timestamp")) .and_then(|v| v.as_str()) { last_ts = Some(payload_ts.to_string()); } })?; Ok(last_ts) } fn get_cursor_last_timestamp(session_file: &PathBuf) -> Result<Option<String>> { Ok(get_cursor_file_timestamp(session_file)) } fn extract_codex_message(entry: &CodexEntry) -> Option<(String, String)> { let entry_type = entry.entry_type.as_deref(); if entry_type == Some("response_item") { let payload = entry.payload.as_ref()?; if payload.get("type").and_then(|v| v.as_str()) != Some("message") { return None; } let role = payload.get("role").and_then(|v| v.as_str())?.to_string(); let content = payload.get("content")?; let text = extract_codex_content_text(content)?; let clean_text = normalize_session_message(&role, &text)?; return Some((role, clean_text)); } if entry_type == Some("event_msg") { let payload = entry.payload.as_ref()?; let payload_type = payload.get("type").and_then(|v| v.as_str()); if payload_type == Some("user_message") { let text = payload.get("message").and_then(|v| v.as_str())?; let clean_text = normalize_session_message("user", text)?; return Some(("user".to_string(), clean_text)); } if payload_type == Some("agent_message") { let text = payload.get("message").and_then(|v| v.as_str())?; let clean_text = normalize_session_message("assistant", text)?; return Some(("assistant".to_string(), clean_text)); } } if entry_type == Some("message") { let role = entry.role.as_deref()?.to_string(); let content = entry.content.as_ref()?; let text = extract_codex_content_text(content)?; let clean_text = normalize_session_message(&role, &text)?; return Some((role, clean_text)); } None } fn normalize_cursor_role(role: &str) -> &str { match role { "assistant" | "assistanlft" => "assistant", "user" => "user", other => other, } } fn extract_cursor_message(entry: &CursorEntry) -> Option<(String, String)> { let role = normalize_cursor_role(entry.role.as_deref()?); if role != "user" && role != "assistant" { return None; } let message = entry.message.as_ref()?; let content = message.content.as_ref()?; let text = extract_message_text(content)?; let clean_text = normalize_session_message(role, &text)?; Some((role.to_string(), clean_text)) } /// Get recent AI session context for the current project. /// Used by commit workflow to provide context for code review. /// Returns the last N exchanges from the most recent sessions. pub fn get_recent_session_context(max_exchanges: usize) -> Result<Option<String>> { let cwd = std::env::current_dir().context("failed to get current directory")?; // Get sessions for Claude, Codex, and Cursor let sessions = read_sessions_for_path(Provider::All, &cwd)?; if sessions.is_empty() { return Ok(None); } // Get the most recent session let recent_session = &sessions[0]; // Read context from the most recent session match read_last_context( &recent_session.session_id, recent_session.provider, max_exchanges, &cwd, ) { Ok(context) => { if context.trim().is_empty() { Ok(None) } else { let provider_name = match recent_session.provider { Provider::Claude => "Claude Code", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; Ok(Some(format!( "=== Recent {} Session Context ===\n\n{}\n\n=== End Session Context ===", provider_name, context ))) } } Err(_) => Ok(None), } } /// Get the .ai/internal/sessions/claude directory for the current project. fn get_ai_sessions_dir() -> Result<PathBuf> { let cwd = std::env::current_dir().context("failed to get current directory")?; Ok(cwd .join(".ai") .join("internal") .join("sessions") .join("claude")) } /// Get the index.json path. fn get_index_path() -> Result<PathBuf> { Ok(get_ai_sessions_dir()?.join("index.json")) } /// Get the notes directory. fn get_notes_dir() -> Result<PathBuf> { Ok(get_ai_sessions_dir()?.join("notes")) } /// Load the session index. fn load_index() -> Result<SessionIndex> { let path = get_index_path()?; if !path.exists() { return Ok(SessionIndex::default()); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; serde_json::from_str(&content).context("failed to parse index.json") } fn load_index_for_path(project_path: &Path) -> Result<SessionIndex> { let path = project_path .join(".ai") .join("internal") .join("sessions") .join("claude") .join("index.json"); if !path.exists() { return Ok(SessionIndex::default()); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; serde_json::from_str(&content).context("failed to parse index.json") } pub fn get_sessions_for_web(project_path: &PathBuf) -> Result<Vec<WebSession>> { let sessions = read_sessions_for_path(Provider::All, project_path)?; if sessions.is_empty() { return Ok(vec![]); } let index = load_index_for_path(project_path).unwrap_or_default(); let mut output = Vec::with_capacity(sessions.len()); for session in sessions { let provider = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "unknown", }; let name = index .sessions .iter() .find(|(_, saved)| saved.id == session.session_id && saved.provider == provider) .map(|(name, _)| name.clone()) .filter(|name| !is_auto_generated_name(name)); let session_messages = read_session_messages_for_path(project_path, &session.session_id, session.provider) .unwrap_or_default(); let started_at = session_messages .started_at .clone() .or_else(|| session.timestamp.clone()); let last_message_at = session_messages .last_message_at .clone() .or_else(|| started_at.clone()); output.push(WebSession { id: session.session_id, provider: provider.to_string(), timestamp: session.timestamp, name, messages: session_messages.messages, started_at, last_message_at, }); } output.sort_by(|a, b| { let a_key = a .last_message_at .as_deref() .or(a.started_at.as_deref()) .unwrap_or(""); let b_key = b .last_message_at .as_deref() .or(b.started_at.as_deref()) .unwrap_or(""); b_key.cmp(a_key) }); Ok(output) } fn read_session_messages_for_path( project_path: &Path, session_id: &str, provider: Provider, ) -> Result<SessionMessages> { match provider { Provider::Codex => read_codex_messages(session_id), Provider::Cursor => read_cursor_messages(session_id), Provider::Claude | Provider::All => read_claude_messages_for_path(project_path, session_id), } } fn read_claude_messages_for_path(project_path: &Path, session_id: &str) -> Result<SessionMessages> { let path_str = project_path.to_string_lossy().to_string(); let project_folder = path_to_project_name(&path_str); let session_file = get_claude_projects_dir() .join(&project_folder) .join(format!("{}.jsonl", session_id)); if !session_file.exists() { bail!("Session file not found: {}", session_file.display()); } let mut messages = Vec::new(); let mut started_at: Option<String> = None; let mut last_message_at: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) else { return; }; let Some(ref msg) = entry.message else { return; }; let role = msg.role.as_deref().unwrap_or("unknown"); if role != "user" && role != "assistant" { return; } let content_text = msg.content.as_ref().and_then(extract_message_text); let Some(content_text) = content_text else { return; }; let Some(clean_text) = normalize_session_message(role, &content_text) else { return; }; push_message(&mut messages, role, &clean_text); if let Some(ts) = entry.timestamp.clone() { if started_at.is_none() { started_at = Some(ts.clone()); } last_message_at = Some(ts); } })?; Ok(SessionMessages { messages, started_at, last_message_at, }) } fn read_codex_messages(session_id: &str) -> Result<SessionMessages> { let session_file = find_codex_session_file(session_id) .ok_or_else(|| anyhow::anyhow!("Codex session file not found"))?; let mut messages = Vec::new(); let mut started_at: Option<String> = None; let mut last_message_at: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { let entry: CodexEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; let Some((role, text)) = extract_codex_message(&entry) else { return; }; push_message(&mut messages, &role, &text); if let Some(ts) = extract_codex_timestamp(&entry) { if started_at.is_none() { started_at = Some(ts.clone()); } last_message_at = Some(ts); } })?; Ok(SessionMessages { messages, started_at, last_message_at, }) } fn read_cursor_messages(session_id: &str) -> Result<SessionMessages> { let session_file = find_cursor_session_file(session_id) .ok_or_else(|| anyhow::anyhow!("Cursor session file not found"))?; let mut messages = Vec::new(); let mut started_at = get_cursor_file_timestamp(&session_file); let mut last_message_at = started_at.clone(); for_each_nonempty_jsonl_line(&session_file, |line| { let entry: CursorEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; let Some((role, text)) = extract_cursor_message(&entry) else { return; }; push_message(&mut messages, &role, &text); })?; if started_at.is_none() && !messages.is_empty() { started_at = Some(chrono::Utc::now().to_rfc3339()); last_message_at = started_at.clone(); } Ok(SessionMessages { messages, started_at, last_message_at, }) } fn extract_codex_timestamp(entry: &CodexEntry) -> Option<String> { if let Some(ts) = entry.timestamp.as_deref() { return Some(ts.to_string()); } entry .payload .as_ref() .and_then(|payload| payload.get("timestamp")) .and_then(|value| value.as_str()) .map(|ts| ts.to_string()) } fn extract_message_text(content_value: &serde_json::Value) -> Option<String> { match content_value { serde_json::Value::String(s) => Some(s.clone()), serde_json::Value::Array(arr) => { let parts: Vec<String> = arr .iter() .filter_map(|item| { let item_type = item.get("type").and_then(|t| t.as_str()); if item_type.is_some() && item_type != Some("text") { return None; } item.get("text") .and_then(|t| t.as_str()) .map(|text| text.to_string()) }) .filter(|text| !text.trim().is_empty()) .collect(); if parts.is_empty() { None } else { Some(parts.join("\n")) } } serde_json::Value::Object(obj) => { if let Some(text) = obj.get("text").and_then(|t| t.as_str()) { return Some(text.to_string()); } None } _ => None, } } fn strip_tagged_block(text: &str, open_tag: &str, close_tag: &str) -> String { let mut result = text.to_string(); while let Some(start) = result.find(open_tag) { if let Some(end) = result[start..].find(close_tag) { let end_pos = start + end + close_tag.len(); result = format!("{}{}", &result[..start], &result[end_pos..]); } else { result = result[..start].to_string(); break; } } result } fn truncate_before_heading(text: &str, heading: &str) -> String { let mut offset = 0usize; for line in text.lines() { if line.trim_start().starts_with(heading) { return text[..offset].trim().to_string(); } offset += line.len(); if offset < text.len() { offset += 1; } } text.trim().to_string() } fn collapse_blank_lines(text: &str) -> String { let mut out = String::new(); let mut saw_blank = false; for line in text.lines() { let trimmed = line.trim_end(); if trimmed.trim().is_empty() { if saw_blank || out.is_empty() { continue; } saw_blank = true; out.push('\n'); continue; } if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } out.push_str(trimmed); out.push('\n'); saw_blank = false; } out.trim().to_string() } fn strip_known_transcript_scaffolding(role: &str, text: &str) -> String { let mut cleaned = strip_system_reminders(text); cleaned = strip_tagged_block(&cleaned, "<environment_context>", "</environment_context>"); cleaned = strip_tagged_block( &cleaned, "<permissions instructions>", "</permissions instructions>", ); cleaned = strip_tagged_block(&cleaned, "<collaboration_mode>", "</collaboration_mode>"); let trimmed = cleaned.trim_start(); if trimmed.starts_with("# AGENTS.md instructions for ") || trimmed.starts_with("# agents.md instructions for ") { return String::new(); } cleaned = truncate_before_heading(&cleaned, "Workflow context:"); cleaned = truncate_before_heading(&cleaned, "Start by checking:"); cleaned = truncate_before_heading(&cleaned, "Designer stack notes:"); if role == "assistant" { let trimmed = cleaned.trim_start(); if trimmed.starts_with("Using `") && (trimmed.contains("workflow") || trimmed.contains("dispatch") || trimmed.contains("because this is")) { return String::new(); } } collapse_blank_lines(&cleaned) } fn normalize_session_message(role: &str, text: &str) -> Option<String> { if role != "user" && role != "assistant" { return None; } let cleaned = if role == "assistant" { strip_thinking_blocks(text) } else { text.to_string() }; let cleaned = strip_known_transcript_scaffolding(role, &cleaned); let cleaned = cleaned.trim(); if cleaned.is_empty() || is_session_boilerplate(cleaned) { return None; } Some(cleaned.to_string()) } fn get_cursor_file_timestamp(path: &Path) -> Option<String> { let modified = fs::metadata(path).ok()?.modified().ok()?; Some(DateTime::<Utc>::from(modified).to_rfc3339()) } fn push_message(messages: &mut Vec<WebSessionMessage>, role: &str, content: &str) { if let Some(last) = messages.last_mut() { if last.role == role { if last.content.trim() == content.trim() { return; } last.content.push_str("\n\n"); last.content.push_str(content); return; } } messages.push(WebSessionMessage { role: role.to_string(), content: content.to_string(), }); } /// Save the session index. fn save_index(index: &SessionIndex) -> Result<()> { let path = get_index_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = serde_json::to_string_pretty(index)?; fs::write(&path, content)?; Ok(()) } /// Get Claude's projects directory. fn get_claude_projects_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".claude").join("projects") } /// Get Codex's projects directory. fn get_codex_projects_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".codex").join("projects") } fn get_codex_sessions_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".codex").join("sessions") } fn get_cursor_projects_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".cursor").join("projects") } /// Convert a path to project folder name (replaces / with -). fn path_to_project_name(path: &str) -> String { path.replace('/', "-") } fn path_to_cursor_project_key(path: &Path) -> String { path.to_string_lossy() .trim_start_matches('/') .replace('/', "-") } fn cursor_project_key_matches_path(project_key: &str, path: &Path) -> bool { let prefix = path_to_cursor_project_key(path); project_key == prefix || project_key .strip_prefix(&prefix) .map(|rest| rest.starts_with('-')) .unwrap_or(false) } fn decode_cursor_project_path(project_key: &str) -> Option<PathBuf> { let mut segments = project_key.split('-'); let root = segments.next()?; let second = segments.next()?; let mut current = PathBuf::from("/").join(root).join(second); if !current.exists() { return None; } let remaining: Vec<String> = segments.map(|segment| segment.to_string()).collect(); let mut index = 0usize; while index < remaining.len() { let entries = fs::read_dir(¤t).ok()?; let mut best_match: Option<(usize, PathBuf)> = None; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let Some(name) = entry.file_name().to_str().map(|value| value.to_string()) else { continue; }; let name_segments: Vec<&str> = name.split('-').collect(); if name_segments.len() > remaining.len().saturating_sub(index) { continue; } let matches = name_segments .iter() .zip(remaining[index..].iter()) .all(|(expected, actual)| *expected == actual); if !matches { continue; } let consumed = name_segments.len(); let should_replace = best_match .as_ref() .map(|(best_consumed, _)| consumed > *best_consumed) .unwrap_or(true); if should_replace { best_match = Some((consumed, path)); } } let Some((consumed, next_path)) = best_match else { return None; }; current = next_path; index += consumed; } Some(current) } fn collect_cursor_project_session_files(project_dir: &Path) -> Vec<PathBuf> { let transcripts_dir = project_dir.join("agent-transcripts"); if !transcripts_dir.exists() { return Vec::new(); } let mut files = Vec::new(); let Ok(entries) = fs::read_dir(&transcripts_dir) else { return files; }; for entry in entries.flatten() { let session_dir = entry.path(); if !session_dir.is_dir() { continue; } let Ok(session_entries) = fs::read_dir(&session_dir) else { continue; }; for session_entry in session_entries.flatten() { let file_path = session_entry.path(); if file_path .extension() .map(|ext| ext == "jsonl") .unwrap_or(false) { files.push(file_path); } } } files } /// Read sessions for the current project, filtered by provider. fn read_sessions_for_project(provider: Provider) -> Result<Vec<AiSession>> { let mut sessions = Vec::new(); if provider == Provider::Claude || provider == Provider::All { sessions.extend(read_provider_sessions(Provider::Claude)?); } if provider == Provider::Codex || provider == Provider::All { sessions.extend(read_provider_sessions(Provider::Codex)?); } if provider == Provider::Cursor || provider == Provider::All { sessions.extend(read_provider_sessions(Provider::Cursor)?); } // Sort by last message timestamp descending (most recent first) sessions.sort_by(|a, b| { let ts_a = a .last_message_at .as_deref() .or(a.timestamp.as_deref()) .unwrap_or(""); let ts_b = b .last_message_at .as_deref() .or(b.timestamp.as_deref()) .unwrap_or(""); ts_b.cmp(ts_a) }); Ok(sessions) } fn resolve_session_target_path(path: Option<&str>) -> Result<PathBuf> { match path.map(str::trim).filter(|value| !value.is_empty()) { Some(raw) => { let expanded = PathBuf::from(shellexpand::tilde(raw).to_string()); let resolved = if expanded.is_absolute() { expanded } else { env::current_dir()?.join(expanded) }; Ok(resolved.canonicalize().unwrap_or(resolved)) } None => { let resolved = env::current_dir().context("failed to get current directory")?; Ok(resolved.canonicalize().unwrap_or(resolved)) } } } fn read_sessions_for_target(provider: Provider, path: Option<&str>) -> Result<Vec<AiSession>> { let target = resolve_session_target_path(path)?; read_sessions_for_path(provider, &target) } /// Read sessions for a project at a specific path. fn read_sessions_for_path(provider: Provider, path: &PathBuf) -> Result<Vec<AiSession>> { let mut sessions = Vec::new(); if provider == Provider::Claude || provider == Provider::All { sessions.extend(read_provider_sessions_for_path(Provider::Claude, path)?); } if provider == Provider::Codex || provider == Provider::All { sessions.extend(read_provider_sessions_for_path(Provider::Codex, path)?); } if provider == Provider::Cursor || provider == Provider::All { sessions.extend(read_provider_sessions_for_path(Provider::Cursor, path)?); } // Sort by last message timestamp descending (most recent first) sessions.sort_by(|a, b| { let ts_a = a .last_message_at .as_deref() .or(a.timestamp.as_deref()) .unwrap_or(""); let ts_b = b .last_message_at .as_deref() .or(b.timestamp.as_deref()) .unwrap_or(""); ts_b.cmp(ts_a) }); Ok(sessions) } /// Read sessions for a specific provider at a given path. fn read_provider_sessions_for_path(provider: Provider, path: &PathBuf) -> Result<Vec<AiSession>> { if provider == Provider::Codex { return read_codex_sessions_for_path(path); } if provider == Provider::Cursor { return read_cursor_sessions_for_path(path); } let path_str = path.to_string_lossy().to_string(); let project_name = path_to_project_name(&path_str); let projects_dir = match provider { Provider::Claude => get_claude_projects_dir(), Provider::Codex => get_codex_projects_dir(), Provider::Cursor => get_cursor_projects_dir(), Provider::All => return Ok(vec![]), }; let project_dir = projects_dir.join(&project_name); if !project_dir.exists() { return Ok(vec![]); } let mut sessions = Vec::new(); let entries = fs::read_dir(&project_dir) .with_context(|| format!("failed to read {}", project_dir.display()))?; for entry in entries { let entry = entry?; let file_path = entry.path(); if file_path.extension().map(|e| e == "jsonl").unwrap_or(false) { let filename = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if filename.starts_with("agent-") { continue; } if let Some(session) = parse_session_file(&file_path, filename, provider) { sessions.push(session); } } } Ok(sessions) } /// Read sessions for a specific provider. fn read_provider_sessions(provider: Provider) -> Result<Vec<AiSession>> { if provider == Provider::Codex { let cwd = std::env::current_dir().context("failed to get current directory")?; return read_codex_sessions_for_path(&cwd); } if provider == Provider::Cursor { let cwd = std::env::current_dir().context("failed to get current directory")?; return read_cursor_sessions_for_path(&cwd); } let cwd = std::env::current_dir()?; let cwd_str = cwd.to_string_lossy().to_string(); let project_name = path_to_project_name(&cwd_str); let projects_dir = match provider { Provider::Claude => get_claude_projects_dir(), Provider::Codex => get_codex_projects_dir(), Provider::Cursor => get_cursor_projects_dir(), Provider::All => return Ok(vec![]), // Should use read_sessions_for_project instead }; let project_dir = projects_dir.join(&project_name); if !project_dir.exists() { debug!( "{:?} project dir not found at {}", provider, project_dir.display() ); return Ok(vec![]); } let mut sessions = Vec::new(); // Read all .jsonl files in the project directory let entries = fs::read_dir(&project_dir) .with_context(|| format!("failed to read {}", project_dir.display()))?; for entry in entries { let entry = entry?; let path = entry.path(); // Only process .jsonl files that look like session IDs (UUID format) if path.extension().map(|e| e == "jsonl").unwrap_or(false) { let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); // Skip agent- prefixed files (subagent sessions) if filename.starts_with("agent-") { continue; } // Parse the session file if let Some(session) = parse_session_file(&path, filename, provider) { sessions.push(session); } } } Ok(sessions) } /// Parse a session .jsonl file to extract metadata. fn parse_session_file(path: &PathBuf, session_id: &str, provider: Provider) -> Option<AiSession> { if provider == Provider::Codex { let (session, _cwd) = parse_codex_session_file(path, session_id)?; return Some(session); } if provider == Provider::Cursor { return parse_cursor_session_file(path, session_id); } let mut timestamp = None; let mut last_message_at = None; let mut last_message = None; let mut first_message = None; let mut error_summary = None; for_each_nonempty_jsonl_line(path, |line| { if let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) { // Get timestamp from first entry if timestamp.is_none() { timestamp = entry.timestamp.clone(); } if let Some(ref msg) = entry.message { let role = msg.role.as_deref(); if role == Some("user") || role == Some("assistant") { if let Some(ref content) = msg.content { if let Some(text) = extract_message_text(content) { if let Some(clean_text) = normalize_session_message(role.unwrap_or("unknown"), &text) { last_message = Some(clean_text); if let Some(ts) = entry.timestamp.clone() { last_message_at = Some(ts); } } } } } } // Get first user message as summary if first_message.is_none() { if let Some(ref msg) = entry.message { if msg.role.as_deref() == Some("user") { if let Some(ref content) = msg.content { first_message = extract_message_text(content) .and_then(|text| normalize_session_message("user", &text)); } } } } // Capture first error summary (useful when no user message exists) if error_summary.is_none() { error_summary = extract_error_summary(&entry); } } }) .ok()?; Some(AiSession { session_id: session_id.to_string(), provider, timestamp, last_message_at, last_message, first_message, error_summary, }) } fn parse_codex_session_file( path: &PathBuf, fallback_id: &str, ) -> Option<(AiSession, Option<PathBuf>)> { let mut timestamp = None; let mut last_message_at = None; let mut last_message = None; let mut first_message = None; let mut error_summary = None; let mut session_id = None; let mut cwd = None; for_each_nonempty_jsonl_line(path, |line| { let entry: CodexEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; if timestamp.is_none() { timestamp = entry.timestamp.clone(); } if let Some((_role, text)) = extract_codex_message(&entry) { if !text.trim().is_empty() { last_message = Some(text); if let Some(ts) = extract_codex_timestamp(&entry) { last_message_at = Some(ts); } } } if entry.entry_type.as_deref() == Some("session_meta") { if let Some(payload) = entry.payload.as_ref() { if session_id.is_none() { session_id = payload .get("id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } if cwd.is_none() { cwd = payload .get("cwd") .and_then(|v| v.as_str()) .map(|s| PathBuf::from(s)); } if timestamp.is_none() { timestamp = payload .get("timestamp") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } } } if first_message.is_none() { if let Some(text) = extract_codex_user_message(&entry) { first_message = Some(text); } } if error_summary.is_none() { if let Some(summary) = extract_codex_error_summary(&entry) { error_summary = Some(summary); } } }) .ok()?; let session = AiSession { session_id: session_id.unwrap_or_else(|| fallback_id.to_string()), provider: Provider::Codex, timestamp, last_message_at, last_message, first_message, error_summary, }; Some((session, cwd)) } fn parse_cursor_session_file(path: &PathBuf, fallback_id: &str) -> Option<AiSession> { let timestamp = get_cursor_file_timestamp(path); let mut last_message = None; let mut first_message = None; for_each_nonempty_jsonl_line(path, |line| { let entry: CursorEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; let Some((role, text)) = extract_cursor_message(&entry) else { return; }; last_message = Some(text.clone()); if first_message.is_none() && role == "user" { first_message = Some(text); } }) .ok()?; Some(AiSession { session_id: fallback_id.to_string(), provider: Provider::Cursor, timestamp: timestamp.clone(), last_message_at: timestamp, last_message, first_message, error_summary: None, }) } fn ai_session_from_codex_recover_row(row: CodexRecoverRow) -> AiSession { let updated_at = DateTime::<Utc>::from_timestamp(row.updated_at, 0) .map(|value| value.to_rfc3339()) .unwrap_or_else(|| row.updated_at.to_string()); let title = row.title.filter(|value| !value.trim().is_empty()); let first_user_message = row .first_user_message .filter(|value| !value.trim().is_empty()); let last_message = title.clone().or_else(|| first_user_message.clone()); AiSession { session_id: row.id, provider: Provider::Codex, timestamp: Some(updated_at.clone()), last_message_at: Some(updated_at), last_message, first_message: first_user_message, error_summary: None, } } fn read_codex_sessions_for_path_from_files(path: &PathBuf) -> Result<Vec<AiSession>> { let sessions_dir = get_codex_sessions_dir(); if !sessions_dir.exists() { return Ok(vec![]); } let mut sessions = Vec::new(); let target = path.to_string_lossy(); for file_path in collect_codex_session_files(&sessions_dir) { let filename = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let Some((session, cwd)) = parse_codex_session_file(&file_path, filename) else { continue; }; if let Some(cwd_path) = cwd { if cwd_path.to_string_lossy() == target { sessions.push(session); } } } Ok(sessions) } fn read_codex_sessions_for_path(path: &PathBuf) -> Result<Vec<AiSession>> { let db_result = (|| -> Result<Vec<AiSession>> { let db_path = codex_state_db_path()?; let schema = load_codex_thread_schema(&db_path)?; let target = path.to_string_lossy().to_string(); let cache_key = format!("target={target}"); let sql = format!( r#" {} where archived = 0 and cwd = ?1 order by updated_at desc "#, codex_recover_select_sql(&schema) ); let rows = with_codex_query_cache(&db_path, "session-list-exact", &cache_key, |conn| { let mut stmt = conn .prepare(&sql) .context("failed to prepare codex session list query")?; let iter = stmt.query_map(params![target], map_codex_recover_row)?; Ok(iter.collect::<rusqlite::Result<Vec<_>>>()?) })?; Ok(rows.into_iter().map(ai_session_from_codex_recover_row).collect()) })(); match db_result { Ok(sessions) => Ok(sessions), Err(err) => { debug!( error = %err, path = %path.display(), "failed to read codex sessions from state db; falling back to session files" ); read_codex_sessions_for_path_from_files(path) } } } fn read_cursor_sessions_for_path(path: &PathBuf) -> Result<Vec<AiSession>> { let projects_dir = get_cursor_projects_dir(); if !projects_dir.exists() { return Ok(vec![]); } let mut sessions = Vec::new(); let entries = fs::read_dir(&projects_dir) .with_context(|| format!("failed to read {}", projects_dir.display()))?; for entry in entries.flatten() { let project_dir = entry.path(); if !project_dir.is_dir() { continue; } let Some(project_key) = project_dir.file_name().and_then(|name| name.to_str()) else { continue; }; if !cursor_project_key_matches_path(project_key, path) { continue; } for file_path in collect_cursor_project_session_files(&project_dir) { let filename = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if let Some(session) = parse_cursor_session_file(&file_path, filename) { sessions.push(session); } } } sessions.sort_by(|a, b| { let ts_a = a .last_message_at .as_deref() .or(a.timestamp.as_deref()) .unwrap_or(""); let ts_b = b .last_message_at .as_deref() .or(b.timestamp.as_deref()) .unwrap_or(""); ts_b.cmp(ts_a) }); Ok(sessions) } fn collect_codex_session_files(root: &PathBuf) -> Vec<PathBuf> { let mut out = Vec::new(); let mut stack = vec![root.clone()]; while let Some(dir) = stack.pop() { let entries = match fs::read_dir(&dir) { Ok(v) => v, Err(_) => continue, }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.extension().map(|e| e == "jsonl").unwrap_or(false) { out.push(path); } } } out } fn codex_session_id_from_path(path: &Path) -> Option<String> { let filename = path.file_stem()?.to_str()?; Some(filename.split('_').next().unwrap_or(filename).to_string()) } fn cursor_session_id_from_path(path: &Path) -> Option<String> { path.file_stem() .and_then(|name| name.to_str()) .map(str::to_string) } fn resolve_explicit_native_session(query: &str, provider: Provider) -> Option<(String, Provider)> { if matches!(provider, Provider::Codex | Provider::All) { if let Some(path) = find_codex_session_file(query) { if let Some(session_id) = codex_session_id_from_path(&path) { return Some((session_id, Provider::Codex)); } } } if matches!(provider, Provider::Cursor | Provider::All) { if let Some(path) = find_cursor_session_file(query) { if let Some(session_id) = cursor_session_id_from_path(&path) { return Some((session_id, Provider::Cursor)); } } } None } fn resolve_session_selection( query: &str, sessions: &[AiSession], index: &SessionIndex, provider: Provider, ) -> Result<(String, Provider)> { if let Some((_, saved)) = index .sessions .iter() .find(|(name, _)| name.as_str() == query) { if let Some(session) = sessions.iter().find(|s| s.session_id == saved.id) { return Ok((saved.id.clone(), session.provider)); } if let Some((session_id, session_provider)) = resolve_explicit_native_session(&saved.id, provider) { return Ok((session_id, session_provider)); } return Ok((saved.id.clone(), Provider::Claude)); } if let Some(session) = sessions .iter() .find(|s| s.session_id == *query || s.session_id.starts_with(query)) { return Ok((session.session_id.clone(), session.provider)); } if let Some((session_id, session_provider)) = resolve_explicit_native_session(query, provider) { return Ok((session_id, session_provider)); } bail!("Session not found: {}", query); } /// Get the most recent session ID for this project. fn get_most_recent_session_id() -> Result<Option<String>> { let sessions = read_sessions_for_project(Provider::All)?; Ok(sessions.first().map(|s| s.session_id.clone())) } fn format_session_ref(session: &AiSession, include_provider: bool) -> String { if !include_provider { return session.session_id.clone(); } let provider = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "ai", }; format!("{provider}:{}", session.session_id) } fn print_latest_session_id(provider: Provider, path: Option<String>) -> Result<()> { let target = resolve_session_target_path(path.as_deref())?; if provider == Provider::Codex { let rows = read_recent_codex_threads(&target, false, 1, None)?; let Some(row) = rows.first() else { bail!("No Codex sessions found for {}", target.display()); }; println!("{}", row.id); return Ok(()); } let sessions = read_sessions_for_path(provider, &target)?; let Some(session) = sessions.first() else { let provider_name = match provider { Provider::Claude => "Claude", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; bail!("No {provider_name} sessions found for {}", target.display()); }; println!("{}", format_session_ref(session, false)); Ok(()) } /// Entry for fzf selection struct FzfSessionEntry { display: String, session_id: String, provider: Provider, } #[derive(Debug, Serialize)] struct ProviderSessionListRow { index: usize, id: String, updated_at: Option<String>, updated_relative: String, preview: String, } /// List all sessions and let user fuzzy-select one to resume. fn list_sessions(provider: Provider) -> Result<()> { // Auto-import any new sessions silently auto_import_sessions()?; let index = load_index()?; let sessions = read_sessions_for_project(provider)?; if index.sessions.is_empty() && sessions.is_empty() { let provider_name = match provider { Provider::Claude => "Claude", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; println!("No {} sessions found for this project.", provider_name); if provider == Provider::Cursor { println!("\nTip: open this repo in Cursor and use its agent to create transcripts."); } else { println!("\nTip: Run `claude` or `codex` in this directory to start a session,"); println!(" then use `f ai save <name>` to bookmark it."); } return Ok(()); } // Build entries for fzf - combine saved metadata with session data let mut entries: Vec<FzfSessionEntry> = Vec::new(); // Process all sessions, enriching with saved names where available for session in &sessions { // Skip sessions without timestamps or content if session.timestamp.is_none() && session.last_message_at.is_none() && session.last_message.is_none() && session.first_message.is_none() && session.error_summary.is_none() { continue; } let relative_time = session .last_message_at .as_deref() .or(session.timestamp.as_deref()) .map(format_relative_time) .unwrap_or_else(|| "".to_string()); // Check if this session has a human-assigned name (not auto-generated) let saved_name = index .sessions .iter() .find(|(_, s)| s.id == session.session_id) .map(|(name, _)| name.as_str()) .filter(|name| !is_auto_generated_name(name)); let summary = session .last_message .as_deref() .or(session.first_message.as_deref()) .or(session.error_summary.as_deref()) .unwrap_or(""); let summary_clean = clean_summary(summary); let id_short = &session.session_id[..8.min(session.session_id.len())]; // Add provider indicator when showing all let provider_tag = if provider == Provider::All { match session.provider { Provider::Claude => "claude | ", Provider::Codex => "codex | ", Provider::Cursor => "cursor | ", Provider::All => "", } } else { "" }; let display = if let Some(name) = saved_name { // For named sessions, show: [provider] name | time | summary format!( "{}{} | {} | {}", provider_tag, name, relative_time, truncate_str(&summary_clean, 40) ) } else { // For other sessions, show: [provider] time | summary format!( "{}{} | {} | {}", provider_tag, relative_time, truncate_str(&summary_clean, 60), id_short ) }; entries.push(FzfSessionEntry { display, session_id: session.session_id.clone(), provider: session.provider, }); } if entries.is_empty() { println!("No sessions available."); return Ok(()); } let has_tty = io::stdin().is_terminal() && io::stdout().is_terminal(); // Check for interactive selection support. if !has_tty || which::which("fzf").is_err() { if !has_tty { println!("Interactive selection unavailable without a TTY."); } else { println!("fzf not found – install it for fuzzy selection."); } println!("\nSessions:"); for entry in &entries { println!("{}", entry.display); } if !has_tty { println!("\nTip: use `f ai codex sessions --path <repo>` for machine-readable selection."); } return Ok(()); } // Run fzf if let Some(selected) = run_session_fzf(&entries)? { if selected.provider == Provider::Cursor { let history = read_session_history(&selected.session_id, selected.provider)?; copy_to_clipboard(&history)?; let line_count = history.lines().count(); println!( "Copied Cursor session {} ({} lines) to clipboard", &selected.session_id[..8.min(selected.session_id.len())], line_count ); return Ok(()); } println!( "Resuming session {}...", &selected.session_id[..8.min(selected.session_id.len())] ); launch_session(&selected.session_id, selected.provider)?; } Ok(()) } /// Run fzf and return the selected session entry. fn run_session_fzf(entries: &[FzfSessionEntry]) -> Result<Option<&FzfSessionEntry>> { let mut child = Command::new("fzf") .arg("--prompt") .arg("ai> ") .arg("--ansi") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for entry in entries { writeln!(stdin, "{}", entry.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selection = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let selection = selection.trim(); if selection.is_empty() { return Ok(None); } Ok(entries.iter().find(|e| e.display == selection)) } /// Launch a session with the appropriate CLI. Returns true if successful, false if failed. fn launch_session(session_id: &str, provider: Provider) -> Result<bool> { launch_session_for_target(session_id, provider, None, None, None, None) } fn new_codex_session_trace(workflow_kind: &str) -> CodexResolveWorkflowTrace { CodexResolveWorkflowTrace { trace_id: new_workflow_trace_id(), span_id: new_workflow_span_id(), parent_span_id: None, workflow_kind: workflow_kind.to_string(), service_name: FLOW_CODEX_TRACE_SERVICE_NAME.to_string(), } } fn direct_codex_trace_query(action: &str, route: &str, session_id: Option<&str>) -> String { match route { "continue-last-direct" => "continue last codex session".to_string(), "new-direct" => "start new codex session".to_string(), "resume-direct" if session_id.is_some() => { format!("resume codex session {}", truncate_recover_id(session_id.unwrap_or_default())) } "resume-direct" => "resume codex session".to_string(), "connect-direct" if session_id.is_some() => { format!("connect codex session {}", truncate_recover_id(session_id.unwrap_or_default())) } "connect-direct" => "connect codex session".to_string(), _ => format!("{action} codex session"), } } fn record_direct_codex_launch_event( action: &str, route: &str, target_path: &Path, launch_path: &Path, session_id: Option<&str>, trace: &CodexResolveWorkflowTrace, ) { let query = direct_codex_trace_query(action, route, session_id); let event = codex_skill_eval::CodexSkillEvalEvent { version: 1, recorded_at_unix: SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0), mode: "direct-launch".to_string(), action: action.to_string(), route: route.to_string(), target_path: target_path.display().to_string(), launch_path: launch_path.display().to_string(), query: query.clone(), session_id: session_id.map(str::to_string), runtime_token: None, runtime_skills: Vec::new(), prompt_context_budget_chars: 0, prompt_chars: query.chars().count(), injected_context_chars: 0, reference_count: 0, trace_id: Some(trace.trace_id.clone()), span_id: Some(trace.span_id.clone()), parent_span_id: trace.parent_span_id.clone(), workflow_kind: Some(trace.workflow_kind.clone()), service_name: Some(trace.service_name.clone()), }; let _ = codex_skill_eval::log_event(&event); } fn launch_session_for_target( session_id: &str, provider: Provider, prompt: Option<&str>, target_path: Option<&Path>, runtime_state_path: Option<&str>, trace: Option<&CodexResolveWorkflowTrace>, ) -> Result<bool> { let status = match provider { Provider::Claude | Provider::All => { // Claude uses: claude --resume <session_id> --dangerously-skip-permissions let mut command = Command::new("claude"); command .arg("--resume") .arg(session_id) .arg("--dangerously-skip-permissions"); if let Some(path) = target_path { command.current_dir(path); } command .status() .with_context(|| "failed to launch claude")? } Provider::Codex => { // Codex uses: codex resume --dangerously-bypass-approvals-and-sandbox <session_id> [prompt] let workdir = target_path .map(Path::to_path_buf) .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); let direct_log = trace.is_none(); let effective_trace = trace .cloned() .unwrap_or_else(|| new_codex_session_trace("resume_session")); let mut command = Command::new(configured_codex_bin_for_workdir(&workdir)); command.arg("resume"); if let Some(path) = target_path { command.current_dir(path); } apply_codex_personal_env_to_command(&mut command); apply_codex_trust_overrides_for(&mut command, target_path); apply_codex_runtime_state_to_command(&mut command, runtime_state_path); apply_codex_trace_env_to_command(&mut command, Some(&effective_trace)); command .arg("--dangerously-bypass-approvals-and-sandbox") .arg(session_id); if let Some(prompt) = prompt.map(str::trim).filter(|value| !value.is_empty()) { command.arg(prompt); } let status = command.status().with_context(|| "failed to launch codex")?; if status.success() && direct_log { record_direct_codex_launch_event( "resume", "resume-direct", &workdir, &workdir, Some(session_id), &effective_trace, ); } status } Provider::Cursor => { bail!( "Cursor transcripts are readable only; use `f cursor list`, `f cursor copy`, or `f cursor context`" ); } }; Ok(status.success()) } fn launch_claude_continue() -> Result<bool> { let status = Command::new("claude") .arg("--continue") .arg("--dangerously-skip-permissions") .status() .with_context(|| "failed to launch claude --continue")?; Ok(status.success()) } fn launch_claude_resume_picker() -> Result<bool> { let status = Command::new("claude") .arg("--resume") .arg("--dangerously-skip-permissions") .status() .with_context(|| "failed to launch claude --resume")?; Ok(status.success()) } fn detect_git_root(path: &Path) -> Option<PathBuf> { let output = Command::new("git") .arg("rev-parse") .arg("--show-toplevel") .current_dir(path) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8(output.stdout).ok()?; let trimmed = stdout.trim(); if trimmed.is_empty() { return None; } Some(PathBuf::from(trimmed)) } fn codex_trusted_paths() -> Vec<PathBuf> { env::current_dir() .ok() .map(|path| codex_trusted_paths_for(&path)) .unwrap_or_default() } fn codex_trusted_paths_for(seed: &Path) -> Vec<PathBuf> { let mut paths = BTreeSet::new(); let raw_cwd = seed.to_path_buf(); paths.insert(raw_cwd.clone()); if let Some(raw_git_root) = detect_git_root(&raw_cwd) { paths.insert(raw_git_root); } if let Ok(canonical_cwd) = raw_cwd.canonicalize() { paths.insert(canonical_cwd.clone()); if let Some(canonical_git_root) = detect_git_root(&canonical_cwd) { paths.insert(canonical_git_root); } } paths.into_iter().collect() } fn codex_projects_override(paths: &[PathBuf]) -> Option<String> { if paths.is_empty() { return None; } let projects = paths .iter() .map(|path| { let escaped = path .display() .to_string() .replace('\\', "\\\\") .replace('"', "\\\""); format!("\"{escaped}\"={{ trust_level=\"trusted\" }}") }) .collect::<Vec<_>>() .join(", "); Some(format!("projects={{ {projects} }}")) } fn apply_codex_trust_overrides(command: &mut Command) { if let Some(override_value) = codex_projects_override(&codex_trusted_paths()) { command.arg("--config").arg(override_value); } } fn apply_codex_trust_overrides_for(command: &mut Command, target_path: Option<&Path>) { let paths = target_path .map(codex_trusted_paths_for) .unwrap_or_else(codex_trusted_paths); if let Some(override_value) = codex_projects_override(&paths) { command.arg("--config").arg(override_value); } } fn apply_codex_runtime_state_to_command(command: &mut Command, runtime_state_path: Option<&str>) { if let Some(path) = runtime_state_path .map(str::trim) .filter(|value| !value.is_empty()) { command.env("FLOW_CODEX_RUNTIME_STATE", path); } } fn apply_codex_trace_env_to_command( command: &mut Command, trace: Option<&CodexResolveWorkflowTrace>, ) { let Some(trace) = trace else { return; }; command.env("FLOW_TRACE_ID", &trace.trace_id); command.env("FLOW_SPAN_ID", &trace.span_id); if let Some(parent_span_id) = trace.parent_span_id.as_deref() { command.env("FLOW_PARENT_SPAN_ID", parent_span_id); } command.env("FLOW_WORKFLOW_KIND", &trace.workflow_kind); command.env("FLOW_TRACE_SERVICE_NAME", &trace.service_name); } fn codex_personal_env_keys() -> Vec<String> { [ "FLOW_CODEX_MAPLE_LOCAL_ENDPOINT", "FLOW_CODEX_MAPLE_LOCAL_INGEST_KEY", "FLOW_CODEX_MAPLE_HOSTED_ENDPOINT", "FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY", "FLOW_CODEX_MAPLE_HOSTED_PUBLIC_INGEST_KEY", "FLOW_CODEX_MAPLE_TRACES_ENDPOINTS", "FLOW_CODEX_MAPLE_INGEST_KEYS", "FLOW_CODEX_MAPLE_SERVICE_NAME", "FLOW_CODEX_MAPLE_SERVICE_VERSION", "FLOW_CODEX_MAPLE_SCOPE_NAME", "FLOW_CODEX_MAPLE_ENV", "FLOW_CODEX_MAPLE_QUEUE_CAPACITY", "FLOW_CODEX_MAPLE_MAX_BATCH_SIZE", "FLOW_CODEX_MAPLE_FLUSH_INTERVAL_MS", "FLOW_CODEX_MAPLE_CONNECT_TIMEOUT_MS", "FLOW_CODEX_MAPLE_REQUEST_TIMEOUT_MS", "MAPLE_API_TOKEN", "MAPLE_MCP_URL", ] .into_iter() .map(str::to_string) .collect() } fn codex_has_explicit_maple_env() -> bool { codex_personal_env_keys().into_iter().any(|key| { env::var(&key) .ok() .map(|value| !value.trim().is_empty()) .unwrap_or(false) }) } fn apply_codex_personal_env_to_command(command: &mut Command) { if codex_has_explicit_maple_env() { return; } let missing_keys: Vec<String> = codex_personal_env_keys() .into_iter() .filter(|key| env::var(key).ok().map(|v| v.trim().is_empty()).unwrap_or(true)) .collect(); if missing_keys.is_empty() { return; } let Ok(values) = flow_env::fetch_local_personal_env_vars(&missing_keys) else { return; }; for (key, value) in values { if !value.trim().is_empty() { command.env(key, value); } } } fn codex_runtime_transport_enabled(target_path: &Path) -> bool { if let Ok(value) = env::var("FLOW_CODEX_RUNTIME_TRANSPORT") { let normalized = value.trim().to_ascii_lowercase(); if matches!(normalized.as_str(), "1" | "true" | "yes" | "on") { return true; } } let bin = configured_codex_bin_for_workdir(target_path); Path::new(&bin) .file_name() .and_then(|value| value.to_str()) .unwrap_or(bin.as_str()) .contains("codex-flow-wrapper") } fn launch_codex_resume_picker() -> Result<bool> { let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut command = Command::new(configured_codex_bin_for_workdir(&cwd)); command .arg("resume") .arg("--dangerously-bypass-approvals-and-sandbox"); apply_codex_personal_env_to_command(&mut command); apply_codex_trust_overrides(&mut command); let status = command .status() .with_context(|| "failed to launch codex resume")?; Ok(status.success()) } fn launch_codex_continue_last_for_target(target_path: Option<&Path>) -> Result<bool> { let workdir = target_path .map(Path::to_path_buf) .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); let trace = new_codex_session_trace("continue_last_session"); let mut command = Command::new(configured_codex_bin_for_workdir(&workdir)); command.arg("resume"); if let Some(path) = target_path { command.current_dir(path); } apply_codex_personal_env_to_command(&mut command); apply_codex_trust_overrides_for(&mut command, target_path); apply_codex_trace_env_to_command(&mut command, Some(&trace)); command .arg("--last") .arg("--dangerously-bypass-approvals-and-sandbox"); let status = command .status() .with_context(|| "failed to launch codex resume --last")?; if status.success() { record_direct_codex_launch_event( "resume", "continue-last-direct", &workdir, &workdir, None, &trace, ); } Ok(status.success()) } fn provider_name(provider: Provider) -> &'static str { match provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "ai", } } fn ensure_provider_tty(provider: Provider, action: &str) -> Result<()> { if io::stdin().is_terminal() && io::stdout().is_terminal() { return Ok(()); } bail!( "{} {} requires an interactive terminal (TTY); run this in a terminal tab (e.g. Zed/Ghostty)", provider_name(provider), action ); } fn print_provider_session_listing( provider: Provider, target: &Path, sessions: &[AiSession], json: bool, ) -> Result<()> { if sessions.is_empty() { let provider_name = match provider { Provider::Claude => "Claude", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; bail!("No {provider_name} sessions found for {}", target.display()); } let rows: Vec<ProviderSessionListRow> = sessions .iter() .enumerate() .map(|(index, session)| { let updated_at = session .last_message_at .clone() .or_else(|| session.timestamp.clone()); let updated_relative = updated_at .as_deref() .map(format_relative_time) .unwrap_or_else(|| "-".to_string()); let preview = session .last_message .as_deref() .or(session.first_message.as_deref()) .or(session.error_summary.as_deref()) .map(clean_summary) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "(no message)".to_string()); ProviderSessionListRow { index: index + 1, id: session.session_id.clone(), updated_at, updated_relative, preview, } }) .collect(); if json { println!( "{}", serde_json::to_string_pretty(&rows).context("failed to encode session list JSON")? ); return Ok(()); } println!( "{} sessions for {}", provider_name(provider), target.display() ); println!(); let index_width = rows .last() .map(|row| row.index.to_string().len()) .unwrap_or(1) .max(1); let updated_width = rows .iter() .map(|row| row.updated_relative.chars().count()) .max() .unwrap_or(7) .max("updated".len()); let id_width = rows .iter() .map(|row| row.id.chars().count()) .max() .unwrap_or(10) .min(36) .max(2); println!( "{:>index_width$} {:<updated_width$} {:<id_width$} preview", "#", "updated", "id", index_width = index_width, updated_width = updated_width, id_width = id_width, ); for row in &rows { println!( "{:>index_width$} {:<updated_width$} {:<id_width$} {}", row.index, row.updated_relative, row.id, truncate_str(&row.preview, 90), index_width = index_width, updated_width = updated_width, id_width = id_width, ); } println!(); println!( "Continue with `f ai {} continue <index|id-prefix> --path {}`", provider_name(provider), shell_words::quote(&target.display().to_string()) ); Ok(()) } fn provider_sessions(provider: Provider, path: Option<String>, json: bool) -> Result<()> { if provider == Provider::All { bail!("sessions requires a specific provider (claude or codex)"); } if provider == Provider::Codex { let target = resolve_session_target_path(path.as_deref())?; let sessions = read_sessions_for_target(provider, path.as_deref())?; return print_provider_session_listing(provider, &target, &sessions, json); } ensure_provider_tty(provider, "sessions")?; let launched = match provider { Provider::Claude => launch_claude_resume_picker()?, Provider::Codex => launch_codex_resume_picker()?, Provider::Cursor => false, Provider::All => false, }; if launched { Ok(()) } else { bail!("failed to open {} session picker", provider_name(provider)) } } fn continue_session( session: Option<String>, path: Option<String>, provider: Provider, ) -> Result<()> { if session.is_some() { return resume_session(session, path, provider); } if provider == Provider::All { bail!("continue requires a specific provider (claude or codex)"); } ensure_provider_tty(provider, "continue")?; if path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() { let target = resolve_session_target_path(path.as_deref())?; let sessions = read_sessions_for_target(provider, path.as_deref())?; let sess = sessions.first().ok_or_else(|| { anyhow::anyhow!( "No {} sessions found for {}", provider_name(provider), target.display() ) })?; println!( "Resuming session {} from {}...", &sess.session_id[..8.min(sess.session_id.len())], target.display() ); if launch_session_for_target( &sess.session_id, sess.provider, None, Some(&target), None, None, )? { return Ok(()); } bail!( "failed to continue {} session {} for {}", provider_name(sess.provider), sess.session_id, target.display() ); } let launched = match provider { Provider::Claude => launch_claude_continue()?, Provider::Codex => launch_codex_continue_last_for_target(None)?, Provider::Cursor => false, Provider::All => false, }; if launched { Ok(()) } else { bail!("failed to continue {} session", provider_name(provider)) } } /// Quick start: continue last session or create new one with dangerous flags. pub fn quick_start_session(provider: Provider) -> Result<()> { if provider == Provider::Codex { let launched = launch_codex_continue_last_for_target(None)?; if !launched { new_session(provider)?; } return Ok(()); } // Auto-import any new sessions silently let _ = auto_import_sessions(); let sessions = read_sessions_for_project(provider)?; // Find first session that has actual content (messages) let valid_session = sessions .iter() .find(|s| s.last_message.is_some() || s.first_message.is_some()); if let Some(sess) = valid_session { let launched = launch_session(&sess.session_id, sess.provider)?; if !launched { // Session not found, start a new one new_session(provider)?; } } else { new_session(provider)?; } Ok(()) } /// Start a new session with dangerous flags (ignores existing sessions). fn new_session(provider: Provider) -> Result<()> { new_session_for_target(provider, None, None, None, None) } fn new_session_for_target( provider: Provider, prompt: Option<&str>, target_path: Option<&Path>, runtime_state_path: Option<&str>, trace: Option<&CodexResolveWorkflowTrace>, ) -> Result<()> { let status = match provider { Provider::Claude | Provider::All => { let mut command = Command::new("claude"); command.arg("--dangerously-skip-permissions"); if let Some(path) = target_path { command.current_dir(path); } command .status() .with_context(|| "failed to launch claude")? } Provider::Codex => { let workdir = target_path .map(Path::to_path_buf) .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); let direct_log = trace.is_none(); let effective_trace = trace .cloned() .unwrap_or_else(|| new_codex_session_trace("new_session")); let mut command = Command::new(configured_codex_bin_for_workdir(&workdir)); if let Some(path) = target_path { command.current_dir(path); } apply_codex_personal_env_to_command(&mut command); apply_codex_trust_overrides_for(&mut command, target_path); apply_codex_runtime_state_to_command(&mut command, runtime_state_path); apply_codex_trace_env_to_command(&mut command, Some(&effective_trace)); command .arg("--yolo") .arg("--sandbox") .arg("danger-full-access"); if let Some(prompt) = prompt.map(str::trim).filter(|value| !value.is_empty()) { command.arg(prompt); } let status = command.status().with_context(|| "failed to launch codex")?; if status.success() && direct_log { record_direct_codex_launch_event( "new", "new-direct", &workdir, &workdir, None, &effective_trace, ); } status } Provider::Cursor => { bail!( "Cursor transcripts are readable only; use `f cursor list`, `f cursor copy`, or `f cursor context`" ); } }; let name = match provider { Provider::Claude | Provider::All => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", }; if !status.success() { bail!("{} exited with status {}", name, status); } Ok(()) } fn find_codex_session( path: Option<String>, query: Vec<String>, exact_cwd: bool, provider: Provider, ) -> Result<()> { let selected = find_best_codex_session_match(path, query, exact_cwd, provider, "find", true)?; resume_session(Some(selected.id.clone()), None, Provider::Codex) } fn find_and_copy_codex_session( path: Option<String>, query: Vec<String>, exact_cwd: bool, provider: Provider, ) -> Result<()> { let selected = find_best_codex_session_match(path, query, exact_cwd, provider, "findAndCopy", false)?; copy_session_history_to_clipboard(&selected.id, Provider::Codex)?; println!( "Session {} found and copied to clipboard", truncate_recover_id(&selected.id) ); Ok(()) } fn find_best_codex_session_match( path: Option<String>, query: Vec<String>, exact_cwd: bool, provider: Provider, action_name: &str, verbose: bool, ) -> Result<CodexRecoverRow> { if provider != Provider::Codex { bail!( "{} is only supported for Codex sessions; use `f ai codex {} ...`", action_name, action_name ); } let query_text = normalize_recover_query(&query).ok_or_else(|| { anyhow::anyhow!( "{} requires a query, for example: `f ai codex {} \"make plan to get designer\"`", action_name, action_name ) })?; let target_path = path .map(|value| canonicalize_recover_path(Some(value))) .transpose()?; let rows = search_codex_threads_for_find(target_path.as_deref(), exact_cwd, &query_text, 5)?; let selected = rows.first().ok_or_else(|| match target_path.as_ref() { Some(target_path) => anyhow::anyhow!( "No matching Codex sessions found for {:?} under {}", query_text, target_path.display() ), None => anyhow::anyhow!("No matching Codex sessions found for {:?}", query_text), })?; if verbose { println!( "Matched Codex session {} | {} | {}", truncate_recover_id(&selected.id), format_unix_ts(selected.updated_at), selected.cwd ); if let Some(first) = selected.first_user_message.as_deref() { println!("Prompt: {}", truncate_recover_text(first)); } else if let Some(title) = selected.title.as_deref() { println!("Title: {}", truncate_recover_text(title)); } } Ok(selected.clone()) } fn recover_codex_sessions( path: Option<String>, query: Vec<String>, exact_cwd: bool, limit: usize, json_output: bool, summary_only: bool, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("recover is only supported for Codex sessions; use `f ai codex recover ...`"); } let query_text = normalize_recover_query(&query); let requested_target_path = canonicalize_recover_path(path)?; let explicit_session_hint = query_text.as_deref().and_then(extract_codex_session_hint); let (target_path, rows) = if let Some(session_hint) = explicit_session_hint.as_deref() { let rows = read_codex_threads_by_session_hint(session_hint, limit.max(1))?; if let Some(first) = rows.first() { (canonicalize_recover_path(Some(first.cwd.clone()))?, rows) } else { ( requested_target_path.clone(), read_recent_codex_threads( &requested_target_path, exact_cwd, limit.max(1), query_text.as_deref(), )?, ) } } else { ( requested_target_path.clone(), read_recent_codex_threads( &requested_target_path, exact_cwd, limit.max(1), query_text.as_deref(), )?, ) }; let output = build_recover_output(&target_path, exact_cwd, query_text, rows); if summary_only { println!("{}", output.summary); return Ok(()); } if json_output { println!( "{}", serde_json::to_string_pretty(&output).context("failed to encode recovery JSON")? ); return Ok(()); } print_recover_output(&output); Ok(()) } fn canonicalize_recover_path(path: Option<String>) -> Result<PathBuf> { let raw = path.unwrap_or_else(|| ".".to_string()); let expanded = shellexpand::tilde(&raw).to_string(); let candidate = PathBuf::from(expanded); let absolute = if candidate.is_absolute() { candidate } else { env::current_dir() .context("failed to determine current directory")? .join(candidate) }; Ok(absolute.canonicalize().unwrap_or(absolute)) } fn normalize_recover_query(parts: &[String]) -> Option<String> { let text = parts.join(" ").trim().to_string(); if text.is_empty() { None } else { Some(text) } } fn recover_query_tokens(query: &str) -> Vec<String> { query .split_whitespace() .map(|part| { part.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_') .to_ascii_lowercase() }) .filter(|part| !part.is_empty()) .collect() } fn looks_like_git_sha(token: &str) -> bool { (7..=40).contains(&token.len()) && token.chars().all(|ch| ch.is_ascii_hexdigit()) } fn looks_like_codex_session_token(token: &str) -> bool { if token.len() < 8 || token.len() > 36 || !token.contains('-') { return false; } let mut hex_chars = 0usize; for ch in token.chars() { if ch == '-' { continue; } if !ch.is_ascii_hexdigit() { return false; } hex_chars += 1; } if hex_chars < 8 { return false; } if token.len() == 36 { let segments: Vec<_> = token.split('-').collect(); if segments.len() != 5 { return false; } let expected = [8usize, 4, 4, 4, 12]; return segments .iter() .zip(expected) .all(|(segment, expected_len)| segment.len() == expected_len); } true } fn extract_codex_session_hints(query: &str) -> Vec<String> { let mut hints = Vec::new(); for token in recover_query_tokens(query) { if looks_like_git_sha(&token) || !looks_like_codex_session_token(&token) { continue; } if !hints.iter().any(|existing| existing == &token) { hints.push(token); if hints.len() >= 2 { break; } } } hints } fn extract_codex_session_hint(query: &str) -> Option<String> { extract_codex_session_hints(query).into_iter().next() } fn extract_codex_session_reference_request( query_text: &str, normalized_query: &str, ) -> Option<CodexSessionReferenceRequest> { if starts_with_codex_session_lookup_only_phrase(normalized_query) { return None; } let session_hints = extract_codex_session_hints(normalized_query); if session_hints.is_empty() { return None; } let user_request = extract_codex_session_reference_user_request(query_text, &session_hints)?; let count = extract_codex_session_reference_count(query_text, &session_hints); Some(CodexSessionReferenceRequest { session_hints, count, user_request, }) } fn starts_with_codex_session_lookup_only_phrase(query: &str) -> bool { [ "open ", "resume ", "continue ", "connect ", "find ", "copy ", "show ", ] .iter() .any(|prefix| query.starts_with(prefix)) } fn extract_codex_session_reference_user_request( query_text: &str, session_hints: &[String], ) -> Option<String> { let query_lower = query_text.to_ascii_lowercase(); let last_hint = session_hints.last()?; let hint_lower = last_hint.to_ascii_lowercase(); let start = query_lower.rfind(&hint_lower)?; let after_hint = query_text.get(start + last_hint.len()..)?.trim_start(); let remainder = strip_codex_session_window_prefix(after_hint) .trim_start_matches(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | ':' | '-')) .trim(); let remainder = strip_codex_session_followup_prefix(remainder); if remainder.is_empty() { None } else { Some(remainder.to_string()) } } fn strip_codex_session_followup_prefix(value: &str) -> &str { let mut remainder = value.trim_start(); loop { let next = if remainder.len() >= 14 && remainder[..14].eq_ignore_ascii_case("codex session ") { Some(&remainder[14..]) } else if remainder.len() >= 11 && remainder[..11].eq_ignore_ascii_case("codex sesh ") { Some(&remainder[11..]) } else if remainder.len() >= 12 && remainder[..12].eq_ignore_ascii_case("codex chat ") { Some(&remainder[12..]) } else if remainder.len() >= 6 && remainder[..6].eq_ignore_ascii_case("codex ") { Some(&remainder[6..]) } else if remainder.len() >= 8 && remainder[..8].eq_ignore_ascii_case("session ") { Some(&remainder[8..]) } else if remainder.len() >= 5 && remainder[..5].eq_ignore_ascii_case("sesh ") { Some(&remainder[5..]) } else if remainder.len() >= 5 && remainder[..5].eq_ignore_ascii_case("chat ") { Some(&remainder[5..]) } else if remainder.len() >= 7 && remainder[..7].eq_ignore_ascii_case("thread ") { Some(&remainder[7..]) } else if remainder.len() >= 4 && remainder[..4].eq_ignore_ascii_case("and ") { Some(&remainder[4..]) } else if remainder.len() >= 5 && remainder[..5].eq_ignore_ascii_case("then ") { Some(&remainder[5..]) } else { None }; match next { Some(rest) => { remainder = rest.trim_start_matches(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | ':' | '-')); } None => return remainder.trim(), } } } fn extract_codex_session_reference_count(query_text: &str, session_hints: &[String]) -> usize { let query_lower = query_text.to_ascii_lowercase(); let Some(last_hint) = session_hints.last() else { return 12; }; let hint_lower = last_hint.to_ascii_lowercase(); let after_hint = query_lower .rfind(&hint_lower) .and_then(|start| query_text.get(start + last_hint.len()..)) .unwrap_or(query_text); let captures = codex_session_window_regex().captures(after_hint); captures .and_then(|caps| caps.get(1)) .and_then(|value| value.as_str().parse::<usize>().ok()) .map(|value| value.clamp(1, 50)) .unwrap_or(12) } fn strip_codex_session_window_prefix(value: &str) -> &str { if let Some(matched) = codex_session_window_regex().find(value) { &value[matched.end()..] } else { value } } fn codex_session_window_regex() -> &'static Regex { static WINDOW_RE: OnceLock<Regex> = OnceLock::new(); WINDOW_RE.get_or_init(|| { Regex::new(r"(?i)^\s*(?:last|past)\s+(\d{1,3})\s+(?:messages?|exchanges?|turns?)\b") .expect("valid session window regex") }) } fn resolve_builtin_codex_session_reference( session_hint: &str, count: usize, ) -> Result<CodexResolvedReference> { let row = read_codex_threads_by_session_hint(session_hint, 1)? .into_iter() .next() .ok_or_else(|| anyhow::anyhow!("No Codex session found for {}", session_hint))?; let excerpt = read_last_context( &row.id, Provider::Codex, count, &PathBuf::from(&row.cwd), )?; Ok(CodexResolvedReference { name: "codex-session".to_string(), source: "session".to_string(), matched: row.id.clone(), command: None, output: render_codex_session_reference(&row, count, &excerpt), }) } fn render_codex_session_reference(row: &CodexRecoverRow, count: usize, excerpt: &str) -> String { let mut lines = vec![ format!("- Codex session: {}", row.id), format!("- Repo cwd: {}", row.cwd), format!("- Updated: {}", format_unix_ts(row.updated_at)), format!("- Included excerpt: last {} exchanges", count), ]; if let Some(title) = row.title.as_deref() { lines.push(format!("- Title: {}", truncate_recover_text(title))); } if let Some(first) = row.first_user_message.as_deref() { lines.push(format!( "- First user message: {}", truncate_recover_text(first) )); } lines.push("Recent transcript excerpt:".to_string()); lines.extend(excerpt.lines().map(str::to_string)); compact_codex_context_block(&lines.join("\n"), 32, 3200) } fn codex_sqlite_home() -> Result<PathBuf> { if let Some(path) = env::var_os("CODEX_SQLITE_HOME") { return Ok(PathBuf::from(path)); } if let Some(path) = env::var_os("CODEX_HOME") { return Ok(PathBuf::from(path)); } let home = dirs::home_dir().context("failed to resolve home directory")?; Ok(home.join(".codex")) } fn parse_codex_versioned_db_filename(file_name: &str, prefix: &str) -> Option<u32> { file_name .strip_prefix(prefix)? .strip_suffix(".sqlite")? .parse::<u32>() .ok() } fn select_codex_state_db_path(sqlite_home: &Path) -> Result<PathBuf> { let mut candidates: Vec<(u32, PathBuf)> = fs::read_dir(sqlite_home) .with_context(|| format!("failed to read {}", sqlite_home.display()))? .filter_map(|entry| entry.ok()) .filter_map(|entry| { let path = entry.path(); let file_name = path.file_name()?.to_str()?; let version = parse_codex_versioned_db_filename(file_name, "state_")?; Some((version, path)) }) .collect(); candidates.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); if let Some((_, path)) = candidates.into_iter().next() { return Ok(path); } let legacy_path = sqlite_home.join("state.sqlite"); if legacy_path.exists() { return Ok(legacy_path); } bail!( "no Codex state_<version>.sqlite database found under {}", sqlite_home.display() ) } fn codex_state_db_path() -> Result<PathBuf> { select_codex_state_db_path(&codex_sqlite_home()?) } fn codex_query_cache_disabled() -> bool { matches!( env::var(CODEX_QUERY_CACHE_ENV_DISABLE) .ok() .as_deref() .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), Some("1" | "true" | "yes" | "on") ) } fn codex_query_cache_root() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()? .join("codex") .join("query-cache")) } fn codex_session_completion_markers_dir() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()? .join("codex") .join("session-completions")) } fn codex_session_completion_scan_limit() -> usize { env::var("FLOW_CODEX_SESSION_COMPLETION_SCAN_LIMIT") .ok() .and_then(|value| value.parse::<usize>().ok()) .map(|value| value.clamp(1, 200)) .unwrap_or(CODEX_SESSION_COMPLETION_DEFAULT_SCAN_LIMIT) } fn codex_session_completion_idle_secs() -> u64 { env::var("FLOW_CODEX_SESSION_COMPLETION_IDLE_SECS") .ok() .and_then(|value| value.parse::<u64>().ok()) .map(|value| value.clamp(15, 3600)) .unwrap_or(CODEX_SESSION_COMPLETION_DEFAULT_IDLE_SECS) } fn prune_codex_session_completion_markers(now_unix: u64) -> Result<()> { let root = codex_session_completion_markers_dir()?; if !root.exists() { return Ok(()); } let keep_cutoff = now_unix.saturating_sub(60 * 24 * 60 * 60); let Ok(entries) = fs::read_dir(&root) else { return Ok(()); }; for entry in entries.flatten() { let path = entry.path(); let Ok(metadata) = entry.metadata() else { continue; }; let modified = metadata .modified() .ok() .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) .map(|value| value.as_secs()) .unwrap_or(now_unix); if modified < keep_cutoff { let _ = fs::remove_file(path); } } Ok(()) } fn claim_codex_session_completion_marker(session_id: &str, assistant_at_unix: u64) -> Result<bool> { let root = codex_session_completion_markers_dir()?; fs::create_dir_all(&root).with_context(|| format!("failed to create {}", root.display()))?; let key = blake3::hash(format!("{session_id}:{assistant_at_unix}").as_bytes()).to_hex(); let path = root.join(format!("{key}.done")); let mut file = match OpenOptions::new().create_new(true).write(true).open(&path) { Ok(file) => file, Err(err) if err.kind() == io::ErrorKind::AlreadyExists => return Ok(false), Err(err) => { return Err(err).with_context(|| format!("failed to create {}", path.display())); } }; writeln!(file, "{session_id}:{assistant_at_unix}") .with_context(|| format!("failed to write {}", path.display()))?; Ok(true) } fn codex_query_cache_entry_count() -> usize { let Ok(root) = codex_query_cache_root() else { return 0; }; fs::read_dir(root) .ok() .into_iter() .flat_map(|entries| entries.flatten()) .filter(|entry| { entry.path().extension().and_then(|value| value.to_str()) == Some("msgpack") }) .count() } fn codex_query_cache_store() -> &'static Mutex<HashMap<PathBuf, CodexQueryCacheEntry>> { static CACHE: OnceLock<Mutex<HashMap<PathBuf, CodexQueryCacheEntry>>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } fn codex_thread_schema_cache() -> &'static Mutex<HashMap<PathBuf, CodexThreadSchemaCacheEntry>> { static CACHE: OnceLock<Mutex<HashMap<PathBuf, CodexThreadSchemaCacheEntry>>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } fn unix_now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0) } fn codex_state_db_stamp(path: &Path) -> Result<CodexStateDbStamp> { let metadata = fs::metadata(path) .with_context(|| format!("failed to stat Codex state db {}", path.display()))?; let modified = metadata .modified() .unwrap_or(SystemTime::UNIX_EPOCH) .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); Ok(CodexStateDbStamp { path: path.display().to_string(), len: metadata.len(), modified_unix_secs: modified, }) } fn read_codex_thread_schema(conn: &Connection) -> Result<CodexThreadSchema> { let mut stmt = conn .prepare("pragma table_info(threads)") .context("failed to prepare threads schema query")?; let columns = stmt .query_map([], |row| row.get::<_, String>(1)) .context("failed to query threads schema")?; let mut names = BTreeSet::new(); for column in columns { names.insert(column?); } Ok(CodexThreadSchema { has_model: names.contains("model"), has_reasoning_effort: names.contains("reasoning_effort"), }) } fn load_codex_thread_schema(db_path: &Path) -> Result<CodexThreadSchema> { let stamp = codex_state_db_stamp(db_path)?; if let Ok(cache) = codex_thread_schema_cache().lock() { if let Some(entry) = cache.get(db_path) { if entry.stamp == stamp { return Ok(entry.schema.clone()); } } } let conn = Connection::open(db_path) .with_context(|| format!("failed to open {}", db_path.display()))?; let schema = read_codex_thread_schema(&conn)?; if let Ok(mut cache) = codex_thread_schema_cache().lock() { cache.insert( db_path.to_path_buf(), CodexThreadSchemaCacheEntry { stamp, schema: schema.clone(), }, ); } Ok(schema) } fn codex_recover_select_sql(schema: &CodexThreadSchema) -> String { let model_expr = if schema.has_model { "model" } else { "NULL as model" }; let reasoning_expr = if schema.has_reasoning_effort { "reasoning_effort" } else { "NULL as reasoning_effort" }; format!( r#" select id, updated_at, cwd, title, first_user_message, git_branch, {model_expr}, {reasoning_expr} from threads "# ) } fn map_codex_recover_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodexRecoverRow> { Ok(CodexRecoverRow { id: row.get("id")?, updated_at: row.get("updated_at")?, cwd: row.get("cwd")?, title: row.get("title")?, first_user_message: row.get("first_user_message")?, git_branch: row.get("git_branch")?, model: row.get("model")?, reasoning_effort: row.get("reasoning_effort")?, }) } fn codex_query_cache_path( stamp: &CodexStateDbStamp, scope: &str, key_material: &str, ) -> Result<PathBuf> { let hash_input = format!("{}\n{}\n{}", stamp.path, scope, key_material); let hash = blake3::hash(hash_input.as_bytes()).to_hex(); Ok(codex_query_cache_root()?.join(format!("{hash}.msgpack"))) } fn read_codex_query_cache(path: &Path, stamp: &CodexStateDbStamp) -> Option<Vec<CodexRecoverRow>> { if codex_query_cache_disabled() { return None; } if let Ok(cache) = codex_query_cache_store().lock() && let Some(entry) = cache.get(path) && entry.version == CODEX_QUERY_CACHE_VERSION && entry.stamp == *stamp { return Some(entry.rows.clone()); } let bytes = fs::read(path).ok()?; let entry = rmp_serde::from_slice::<CodexQueryCacheEntry>(&bytes).ok()?; if entry.version != CODEX_QUERY_CACHE_VERSION || entry.stamp != *stamp { return None; } if let Ok(mut cache) = codex_query_cache_store().lock() { cache.insert(path.to_path_buf(), entry.clone()); } Some(entry.rows) } fn write_codex_query_cache(path: &Path, entry: &CodexQueryCacheEntry) -> Result<()> { if codex_query_cache_disabled() { return Ok(()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { format!( "failed to create Codex query cache dir {}", parent.display() ) })?; } let bytes = rmp_serde::to_vec(entry).context("failed to encode Codex query cache")?; let tmp_path = path.with_extension(format!( "msgpack.tmp.{}.{}", std::process::id(), unix_now_secs() )); fs::write(&tmp_path, bytes) .with_context(|| format!("failed to write Codex query cache {}", tmp_path.display()))?; if let Err(err) = fs::rename(&tmp_path, path) { if path.exists() { let _ = fs::remove_file(path); fs::rename(&tmp_path, path).with_context(|| { format!("failed to finalize Codex query cache {}", path.display()) })?; } else { return Err(err).with_context(|| { format!("failed to finalize Codex query cache {}", path.display()) }); } } if let Ok(mut cache) = codex_query_cache_store().lock() { cache.insert(path.to_path_buf(), entry.clone()); } Ok(()) } fn with_codex_query_cache<F>( db_path: &Path, scope: &str, key_material: &str, query: F, ) -> Result<Vec<CodexRecoverRow>> where F: FnOnce(&Connection) -> Result<Vec<CodexRecoverRow>>, { let stamp = codex_state_db_stamp(db_path)?; let cache_path = codex_query_cache_path(&stamp, scope, key_material)?; if let Some(rows) = read_codex_query_cache(&cache_path, &stamp) { return Ok(rows); } let conn = Connection::open(db_path) .with_context(|| format!("failed to open {}", db_path.display()))?; let rows = query(&conn)?; let entry = CodexQueryCacheEntry { version: CODEX_QUERY_CACHE_VERSION, stamp, rows: rows.clone(), }; if let Err(err) = write_codex_query_cache(&cache_path, &entry) { debug!(path = %cache_path.display(), error = %err, "failed to write codex query cache"); } Ok(rows) } fn escape_like(value: &str) -> String { value .replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") } fn read_recent_codex_threads( target_path: &Path, exact_cwd: bool, limit: usize, query: Option<&str>, ) -> Result<Vec<CodexRecoverRow>> { match codexd::query_recent(target_path, exact_cwd, limit, query) { Ok(rows) => Ok(rows), Err(err) => { debug!(error = %err, "codexd recent query failed; falling back to local query"); read_recent_codex_threads_local(target_path, exact_cwd, limit, query) } } } pub(crate) fn read_recent_codex_threads_local( target_path: &Path, exact_cwd: bool, limit: usize, query: Option<&str>, ) -> Result<Vec<CodexRecoverRow>> { let db_path = codex_state_db_path()?; let schema = load_codex_thread_schema(&db_path)?; let target = target_path.to_string_lossy().to_string(); let like_target = format!("{}/%", escape_like(&target)); let fetch_limit = (limit.max(3) * 12).min(120); let cache_key = format!("target={target}\nexact={exact_cwd}\nfetch_limit={fetch_limit}"); let sql_exact = format!( r#" {} where archived = 0 and cwd = ?1 order by updated_at desc limit ?2 "#, codex_recover_select_sql(&schema) ); let sql_tree = format!( r#" {} where archived = 0 and (cwd = ?1 or cwd like ?2 escape '\') order by updated_at desc limit ?3 "#, codex_recover_select_sql(&schema) ); let mut rows = with_codex_query_cache(&db_path, "recent", &cache_key, |conn| { if exact_cwd { let mut stmt = conn .prepare(&sql_exact) .context("failed to prepare exact recover query")?; let iter = stmt.query_map(params![target, fetch_limit as i64], map_codex_recover_row)?; Ok(iter.collect::<rusqlite::Result<Vec<_>>>()?) } else { let mut stmt = conn .prepare(&sql_tree) .context("failed to prepare subtree recover query")?; let iter = stmt.query_map( params![target, like_target, fetch_limit as i64], map_codex_recover_row, )?; Ok(iter.collect::<rusqlite::Result<Vec<_>>>()?) } })?; rank_recover_rows(&mut rows, query); rows.truncate(limit.max(1)); Ok(rows) } fn read_recent_codex_threads_global_local(limit: usize) -> Result<Vec<CodexRecoverRow>> { let db_path = codex_state_db_path()?; let schema = load_codex_thread_schema(&db_path)?; let fetch_limit = limit.clamp(1, 200); let cache_key = format!("limit={fetch_limit}"); let sql = format!( r#" {} where archived = 0 order by updated_at desc limit ?1 "#, codex_recover_select_sql(&schema) ); with_codex_query_cache(&db_path, "recent-global", &cache_key, |conn| { let mut stmt = conn .prepare(&sql) .context("failed to prepare global recover query")?; let iter = stmt.query_map(params![fetch_limit as i64], map_codex_recover_row)?; Ok(iter.collect::<rusqlite::Result<Vec<_>>>()?) }) } fn read_codex_threads_by_session_hint( session_hint: &str, limit: usize, ) -> Result<Vec<CodexRecoverRow>> { match codexd::query_session_hint(session_hint, limit) { Ok(rows) => Ok(rows), Err(err) => { debug!( error = %err, "codexd session hint query failed; falling back to local query" ); read_codex_threads_by_session_hint_local(session_hint, limit) } } } pub(crate) fn read_codex_threads_by_session_hint_local( session_hint: &str, limit: usize, ) -> Result<Vec<CodexRecoverRow>> { let db_path = codex_state_db_path()?; let schema = load_codex_thread_schema(&db_path)?; let normalized_hint = session_hint.trim().to_ascii_lowercase(); if normalized_hint.is_empty() { return Ok(vec![]); } let cache_key = format!("hint={normalized_hint}\nlimit={}", limit.max(1)); let sql = format!( r#" {} where archived = 0 and (lower(id) = ?1 or lower(id) like ?2 escape '\') order by case when lower(id) = ?1 then 0 else 1 end, updated_at desc limit ?3 "#, codex_recover_select_sql(&schema) ); let prefix_like = format!("{}%", escape_like(&normalized_hint)); with_codex_query_cache(&db_path, "session-hint", &cache_key, |conn| { let mut stmt = conn .prepare(&sql) .context("failed to prepare explicit session recover query")?; let iter = stmt.query_map( params![normalized_hint, prefix_like, limit.max(1) as i64], map_codex_recover_row, )?; Ok(iter.collect::<rusqlite::Result<Vec<_>>>()?) }) } fn search_codex_threads_for_find( target_path: Option<&Path>, exact_cwd: bool, query: &str, limit: usize, ) -> Result<Vec<CodexRecoverRow>> { match codexd::query_find(target_path, exact_cwd, query, limit) { Ok(rows) => Ok(rows), Err(err) => { debug!(error = %err, "codexd find query failed; falling back to local query"); search_codex_threads_for_find_local(target_path, exact_cwd, query, limit) } } } pub(crate) fn search_codex_threads_for_find_local( target_path: Option<&Path>, exact_cwd: bool, query: &str, limit: usize, ) -> Result<Vec<CodexRecoverRow>> { let normalized_query = query.trim().to_lowercase(); if normalized_query.is_empty() { return Ok(vec![]); } if let Some(session_hint) = extract_codex_session_hint(&normalized_query) { let rows = read_codex_threads_by_session_hint_local(&session_hint, limit.max(1))?; if !rows.is_empty() { return Ok(rows); } } let db_path = codex_state_db_path()?; let schema = load_codex_thread_schema(&db_path)?; let mut sql = codex_recover_select_sql(&schema); sql.push_str("where archived = 0\n"); let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); if let Some(target_path) = target_path { let target = target_path.to_string_lossy().to_string(); if exact_cwd { sql.push_str(" and cwd = ?\n"); params_vec.push(Box::new(target)); } else { sql.push_str(" and (cwd = ? or cwd like ? escape '\\')\n"); params_vec.push(Box::new(target.clone())); params_vec.push(Box::new(format!("{}/%", escape_like(&target)))); } } let search_terms = codex_find_search_terms(&normalized_query); let mut clauses = Vec::new(); let mut search_columns = vec!["id", "first_user_message", "title", "git_branch", "cwd"]; if schema.has_model { search_columns.push("model"); } if schema.has_reasoning_effort { search_columns.push("reasoning_effort"); } for term in search_terms { let pattern = format!("%{}%", escape_like(&term)); for column in &search_columns { clauses.push(format!("lower(coalesce({column}, '')) like ? escape '\\'")); params_vec.push(Box::new(pattern.clone())); } } if !clauses.is_empty() { sql.push_str(" and ("); sql.push_str(&clauses.join(" or ")); sql.push_str(")\n"); } sql.push_str("order by updated_at desc\nlimit ?\n"); let fetch_limit = (limit.max(5) * 20).min(200); params_vec.push(Box::new(fetch_limit as i64)); let scope_target = target_path .map(|path| path.display().to_string()) .unwrap_or_default(); let cache_key = format!( "query={normalized_query}\nexact={exact_cwd}\ntarget={scope_target}\nfetch_limit={fetch_limit}" ); let mut rows = with_codex_query_cache(&db_path, "find", &cache_key, |conn| { let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn .prepare(&sql) .context("failed to prepare Codex find query")?; let iter = stmt.query_map(params_refs.as_slice(), map_codex_recover_row)?; Ok(iter.collect::<rusqlite::Result<Vec<_>>>()?) })?; rank_recover_rows(&mut rows, Some(&normalized_query)); rows.truncate(limit.max(1)); Ok(rows) } fn codex_find_search_terms(query: &str) -> Vec<String> { let normalized = query.trim().to_lowercase(); if normalized.is_empty() { return vec![]; } let mut terms = vec![normalized.clone()]; let mut seen = BTreeSet::from([normalized]); for token in tokenize_recover_query(query) { if token.len() <= 2 { continue; } if seen.insert(token.clone()) { terms.push(token); } } terms } fn tokenize_recover_query(query: &str) -> Vec<String> { query .split(|ch: char| { !ch.is_ascii_alphanumeric() && ch != '/' && ch != '-' && ch != '_' && ch != '#' }) .filter(|part| !part.is_empty()) .map(|part| part.to_lowercase()) .filter(|part| part.len() > 1) .collect() } fn rank_recover_rows(rows: &mut Vec<CodexRecoverRow>, query: Option<&str>) { let normalized_query = query.map(|q| q.to_lowercase()).unwrap_or_default(); let tokens = tokenize_recover_query(&normalized_query); rows.sort_by(|a, b| { let score_a = recover_row_score(a, &normalized_query, &tokens); let score_b = recover_row_score(b, &normalized_query, &tokens); score_b .cmp(&score_a) .then_with(|| b.updated_at.cmp(&a.updated_at)) .then_with(|| a.cwd.cmp(&b.cwd)) }); if !tokens.is_empty() && rows .iter() .all(|row| recover_row_score(row, &normalized_query, &tokens) == 0) { rows.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); } } fn recover_row_score(row: &CodexRecoverRow, normalized_query: &str, tokens: &[String]) -> i64 { if tokens.is_empty() && normalized_query.is_empty() { return 0; } let id = row.id.to_lowercase(); let cwd = row.cwd.to_lowercase(); let branch = row.git_branch.clone().unwrap_or_default().to_lowercase(); let model = row.model.clone().unwrap_or_default().to_lowercase(); let reasoning_effort = row .reasoning_effort .clone() .unwrap_or_default() .to_lowercase(); let title = row.title.clone().unwrap_or_default().to_lowercase(); let first = row .first_user_message .clone() .unwrap_or_default() .to_lowercase(); let mut score = 0i64; if !normalized_query.is_empty() { if id == normalized_query { score += 600; } else if id.starts_with(normalized_query) { score += 500; } else if id.contains(normalized_query) { score += 300; } if first.contains(normalized_query) { score += 120; } if title.contains(normalized_query) { score += 90; } if branch.contains(normalized_query) { score += 70; } if model.contains(normalized_query) { score += 65; } if reasoning_effort.contains(normalized_query) { score += 30; } if cwd.contains(normalized_query) { score += 60; } } for token in tokens { if id.starts_with(token) { score += 90; } else if id.contains(token) { score += 60; } if first.contains(token) { score += 18; } if title.contains(token) { score += 14; } if branch.contains(token) { score += 12; } if model.contains(token) { score += 12; } if reasoning_effort.contains(token) { score += 6; } if cwd.contains(token) { score += 8; } } score } fn build_recover_output( target_path: &Path, exact_cwd: bool, query: Option<String>, rows: Vec<CodexRecoverRow>, ) -> CodexRecoverOutput { let candidates: Vec<CodexRecoverCandidate> = rows .into_iter() .map(|row| CodexRecoverCandidate { id: row.id, updated_at: format_unix_ts(row.updated_at), updated_at_unix: row.updated_at, cwd: row.cwd, git_branch: row.git_branch.filter(|value| !value.trim().is_empty()), model: row.model.filter(|value| !value.trim().is_empty()), reasoning_effort: row .reasoning_effort .filter(|value| !value.trim().is_empty()), title: row.title.filter(|value| !value.trim().is_empty()), first_user_message: row .first_user_message .filter(|value| !value.trim().is_empty()), }) .collect(); let recommended_route = infer_recover_route( target_path, query.as_deref().unwrap_or_default(), &candidates, ); let summary = build_recover_summary(target_path, exact_cwd, &recommended_route, &candidates); CodexRecoverOutput { target_path: target_path.to_string_lossy().to_string(), exact_cwd, query, recommended_route, summary, candidates, } } fn infer_recover_route( target_path: &Path, _query: &str, candidates: &[CodexRecoverCandidate], ) -> String { if let Some(candidate) = candidates.first() { let candidate_cwd = Path::new(&candidate.cwd); if candidate_cwd != target_path { return format!( "cd {} && f ai codex resume {}", shell_escape_path(candidate_cwd), candidate.id ); } return format!("f ai codex resume {}", candidate.id); } "f ai codex new".to_string() } fn shell_escape_path(path: &Path) -> String { let display = path.to_string_lossy(); if display .chars() .all(|ch| ch.is_ascii_alphanumeric() || "/-._~".contains(ch)) { return display.to_string(); } format!("'{}'", display.replace('\'', "'\"'\"'")) } fn build_recover_summary( target_path: &Path, exact_cwd: bool, recommended_route: &str, candidates: &[CodexRecoverCandidate], ) -> String { let mut lines = Vec::new(); let mode = if exact_cwd { "exact cwd" } else { "repo-tree" }; lines.push(format!( "Recovered recent Codex context for {} ({mode} lookup).", target_path.display() )); if candidates.is_empty() { lines.push("No recent matching Codex sessions found.".to_string()); lines.push(format!("Recommended route: {}", recommended_route)); return lines.join("\n"); } for candidate in candidates.iter().take(3) { let message = candidate .first_user_message .as_deref() .or(candidate.title.as_deref()) .map(truncate_recover_text) .unwrap_or_else(|| "(no stored prompt text)".to_string()); let branch = candidate .git_branch .as_deref() .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()); lines.push(format!( "- {} | {} | {} | {} | {}", truncate_recover_id(&candidate.id), candidate.updated_at, branch, candidate.cwd, message )); } lines.push(format!("Recommended route: {}", recommended_route)); lines.join("\n") } fn truncate_recover_id(value: &str) -> String { value.chars().take(8).collect() } fn truncate_recover_text(value: &str) -> String { let clean = value.split_whitespace().collect::<Vec<_>>().join(" "); if clean.chars().count() <= 110 { return clean; } let truncated: String = clean.chars().take(107).collect(); format!("{truncated}...") } fn format_unix_ts(ts: i64) -> String { DateTime::<Utc>::from_timestamp(ts, 0) .map(|value| value.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_else(|| ts.to_string()) } fn codex_model_label(model: Option<&str>, reasoning_effort: Option<&str>) -> Option<String> { match ( model.map(str::trim).filter(|value| !value.is_empty()), reasoning_effort .map(str::trim) .filter(|value| !value.is_empty()), ) { (Some(model), Some(reasoning_effort)) => Some(format!("{model} [{reasoning_effort}]")), (Some(model), None) => Some(model.to_string()), (None, Some(reasoning_effort)) => Some(format!("reasoning {reasoning_effort}")), (None, None) => None, } } fn print_recover_output(output: &CodexRecoverOutput) { println!("Target path: {}", output.target_path); println!( "Search mode: {}", if output.exact_cwd { "exact cwd" } else { "repo-tree" } ); if let Some(query) = output.query.as_deref() { println!("Query: {}", query); } println!("Recommended route: {}", output.recommended_route); println!(); if output.candidates.is_empty() { println!("No recent matching Codex sessions found."); return; } println!("Recent sessions:"); for candidate in &output.candidates { println!( "- {} | {} | {}", truncate_recover_id(&candidate.id), candidate.updated_at, candidate.cwd ); if let Some(branch) = candidate.git_branch.as_deref() { println!(" branch: {}", branch); } if let Some(model) = codex_model_label( candidate.model.as_deref(), candidate.reasoning_effort.as_deref(), ) { println!(" model: {}", model); } if let Some(first) = candidate.first_user_message.as_deref() { println!(" first: {}", truncate_recover_text(first)); } else if let Some(title) = candidate.title.as_deref() { println!(" title: {}", truncate_recover_text(title)); } } println!(); println!("Summary:"); println!("{}", output.summary); } fn open_codex_session( path: Option<String>, query: Vec<String>, exact_cwd: bool, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("open is only supported for Codex sessions; use `f codex open ...`"); } ensure_provider_tty(Provider::Codex, "open")?; let plan = build_codex_open_plan(path, query, exact_cwd)?; record_codex_open_plan(&plan, "open"); execute_codex_open_plan(&plan) } fn connect_codex_session( path: Option<String>, query: Vec<String>, exact_cwd: bool, json_output: bool, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("connect is only supported for Codex sessions; use `f codex connect ...`"); } let target_path = resolve_codex_connect_target_path(path)?; let query_text = query.join(" ").trim().to_string(); let normalized_query = query_text.to_ascii_lowercase(); let resolved = if query_text.is_empty() { read_recent_codex_threads(&target_path, exact_cwd, 1, None)? .into_iter() .next() .map(|row| (row, "latest recent session".to_string())) } else { resolve_codex_session_lookup(&target_path, exact_cwd, &query_text, &normalized_query)? }; let Some((row, reason)) = resolved else { if query_text.is_empty() { bail!("No Codex sessions found for {}", target_path.display()); } bail!( "{}", build_codex_open_no_match_message(&target_path, exact_cwd, &query_text)? ); }; if json_output { println!( "{}", serde_json::to_string_pretty(&json!({ "id": row.id, "cwd": row.cwd, "updatedAtUnix": row.updated_at, "title": row.title, "firstUserMessage": row.first_user_message, "gitBranch": row.git_branch, "model": row.model, "reasoningEffort": row.reasoning_effort, "reason": reason, "targetPath": target_path.display().to_string(), "exactCwd": exact_cwd, "query": if query_text.is_empty() { None::<String> } else { Some(query_text) }, })) .context("failed to encode codex connect JSON")? ); return Ok(()); } ensure_provider_tty(Provider::Codex, "connect")?; let connect_summary = if query_text.is_empty() { row.first_user_message .as_deref() .and_then(codex_text::sanitize_codex_query_text) .or_else(|| row.title.as_deref().map(str::trim).map(str::to_string)) .unwrap_or_else(|| "resume latest recent session".to_string()) } else { query_text.clone() }; let mut connect_event = activity_log::ActivityEvent::done("codex.connect", connect_summary); connect_event.route = Some(if query_text.is_empty() { "latest".to_string() } else { "query".to_string() }); connect_event.target_path = Some(target_path.display().to_string()); connect_event.launch_path = Some(row.cwd.clone()); connect_event.session_id = Some(row.id.clone()); connect_event.source = Some("codex-connect".to_string()); let _ = activity_log::append_daily_event(connect_event); let launch_path = PathBuf::from(&row.cwd); println!( "Resuming session {} from {}...", &row.id[..8.min(row.id.len())], launch_path.display() ); if launch_session_for_target( &row.id, Provider::Codex, None, Some(&launch_path), None, None, )? { return Ok(()); } bail!( "failed to connect to codex session {} for {}", row.id, launch_path.display() ) } fn resolve_codex_input( path: Option<String>, query: Vec<String>, exact_cwd: bool, json_output: bool, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("resolve is only supported for Codex sessions; use `f codex resolve ...`"); } let (query, json_output) = normalize_codex_resolve_args(query, json_output); let plan = build_codex_open_plan(path, query, exact_cwd)?; record_codex_open_plan(&plan, "resolve"); if json_output { println!( "{}", serde_json::to_string_pretty(&plan).context("failed to encode Codex resolve JSON")? ); return Ok(()); } print_codex_open_plan(&plan); Ok(()) } pub fn codex_resolve_inspector( path: Option<String>, query: String, exact_cwd: bool, ) -> Result<CodexResolveInspectorResponse> { let plan = build_codex_open_plan(path, vec![query], exact_cwd)?; let runtime_skills = load_runtime_skills_from_plan(&plan)?; let workflow = build_codex_resolve_workflow_explanation(&plan, &runtime_skills); Ok(CodexResolveInspectorResponse { action: plan.action, route: plan.route, reason: plan.reason, target_path: plan.target_path, launch_path: plan.launch_path, query: plan.query, session_id: plan.session_id, prompt: plan.prompt, references: plan .references .into_iter() .map(|reference| CodexResolveReferenceSnapshot { name: reference.name, source: reference.source, matched: reference.matched, command: reference.command, output: reference.output, }) .collect(), runtime_state_path: plan.runtime_state_path, runtime_skills, prompt_context_budget_chars: plan.prompt_context_budget_chars, max_resolved_references: plan.max_resolved_references, prompt_chars: plan.prompt_chars, injected_context_chars: plan.injected_context_chars, trace: plan.trace, workflow, }) } fn build_codex_resolve_workflow_explanation( plan: &CodexOpenPlan, runtime_skills: &[CodexResolveRuntimeSkillSnapshot], ) -> Option<CodexResolveWorkflowExplanation> { if let Some(reference) = plan.references.iter().find(|reference| reference.name == "pr-feedback") { return Some(build_pr_feedback_workflow_explanation( plan, reference, runtime_skills, )); } if let Some(reference) = plan .references .iter() .find(|reference| reference.name == "commit-workflow") { let repo_root = Path::new(&plan.target_path); let kit_gate = detect_commit_workflow_kit_gate(repo_root); return Some(CodexResolveWorkflowExplanation { id: "commit-workflow".to_string(), title: "Commit workflow".to_string(), summary: "Flow recognized a high-confidence commit request and turned it into a guarded commit workflow instead of passing plain `commit` text through to Codex.".to_string(), trigger: "High-confidence commit language like `commit`, `commit and push`, or `review and commit`.".to_string(), generated_by: "flow backend route metadata".to_string(), packet: CodexResolveWorkflowPacket { kind: "commit_workflow".to_string(), compact_summary: "Compact commit packet seeded with repo status and diff stats instead of a full pasted diff.".to_string(), default_view: "Start with git status and compact diff stats. Open the full diff or long repo instructions only when the compact view does not explain the risk.".to_string(), expansion_rules: vec![ "Read the compact repo snapshot first.".to_string(), "Inspect the exact local diff and adjacent call sites only after the compact view tells you where to look.".to_string(), "Use repo AGENTS/review instructions as binding constraints, but do not expand them into the prompt unless the change depends on them.".to_string(), ], trace: plan.trace.clone(), validation_plan: { let mut plan = vec![ CodexResolveWorkflowValidation { label: "Diff inspection".to_string(), tier: "targeted".to_string(), detail: "Inspect the actual local diff and adjacent call sites before deciding on the final commit shape.".to_string(), command: None, }, CodexResolveWorkflowValidation { label: "Smallest safety check".to_string(), tier: "targeted".to_string(), detail: "Run the smallest test, lint, or manual check that can falsify the change before committing.".to_string(), command: None, }, ]; if let Some(command) = kit_gate { plan.push(CodexResolveWorkflowValidation { label: "Deterministic repo gate".to_string(), tier: "targeted".to_string(), detail: "Run the repo's deterministic gate before the final commit when one is available.".to_string(), command: Some(command), }); } if let Some(command) = reference.command.clone() { plan.push(CodexResolveWorkflowValidation { label: "Final guarded commit lane".to_string(), tier: "operator".to_string(), detail: "Use the slower Flow-assisted commit path for the final review and commit synthesis instead of a fast blind commit.".to_string(), command: Some(command), }); } plan }, }, commands: reference .command .as_deref() .map(|command| { vec![CodexResolveWorkflowCommand { label: "Preferred command".to_string(), command: command.to_string(), }] }) .unwrap_or_default(), artifacts: Vec::new(), steps: vec![ CodexResolveWorkflowStep { title: "Inspect the real repo state".to_string(), detail: "Flow snapshots the repo status and diff context before Codex starts so the commit flow is grounded in actual local changes.".to_string(), }, CodexResolveWorkflowStep { title: "Inject the commit contract".to_string(), detail: "The prompt includes a commit contract focused on correctness, regression risk, performance, robustness, and repo `AGENTS.md` compliance.".to_string(), }, CodexResolveWorkflowStep { title: "Bias toward deterministic gates".to_string(), detail: "Repo-specific gates such as Kit lint/review are surfaced in the contract so the commit lane does not depend only on model judgment.".to_string(), }, ], notes: vec![ format!("Route: {}", plan.route), format!("Reason: {}", plan.reason), ], }); } if let Some(reference) = plan .references .iter() .find(|reference| reference.name == "sync-workflow") { return Some(CodexResolveWorkflowExplanation { id: "sync-workflow".to_string(), title: "Sync workflow".to_string(), summary: "Flow recognized guarded sync language and routed it into the repo's safe sync workflow instead of leaving Codex to improvise branch sync behavior.".to_string(), trigger: "High-confidence sync language like `sync branch` in a supported repo/workspace.".to_string(), generated_by: "flow backend route metadata".to_string(), packet: CodexResolveWorkflowPacket { kind: "sync_workflow".to_string(), compact_summary: "Compact sync packet carrying the guarded repo sync command and repo workflow contract.".to_string(), default_view: "Start with the repo-specific sync contract. Only expand broader branch history or repo instructions if the guarded sync command reports a blocker.".to_string(), expansion_rules: vec![ "Use the repo's guarded sync path first.".to_string(), "Inspect additional branch history only when sync reports a blocker.".to_string(), "Keep sync explanations branch-aware and compact instead of replaying full Git/JJ history.".to_string(), ], trace: plan.trace.clone(), validation_plan: vec![ CodexResolveWorkflowValidation { label: "Guarded sync command".to_string(), tier: "targeted".to_string(), detail: "Use the repo sync contract rather than improvising Git/JJ steps.".to_string(), command: reference.command.clone(), }, CodexResolveWorkflowValidation { label: "Post-sync status check".to_string(), tier: "targeted".to_string(), detail: "Confirm what changed, whether the branch is now synced, and whether any blocker remains.".to_string(), command: None, }, ], }, commands: reference .command .as_deref() .map(|command| { vec![CodexResolveWorkflowCommand { label: "Preferred command".to_string(), command: command.to_string(), }] }) .unwrap_or_default(), artifacts: Vec::new(), steps: vec![ CodexResolveWorkflowStep { title: "Map plain sync language to the repo workflow".to_string(), detail: "Flow chooses the repo-specific sync command so branch movement stays consistent with the local workflow instead of defaulting to raw git operations.".to_string(), }, CodexResolveWorkflowStep { title: "Keep the main prompt compact".to_string(), detail: "Only the sync contract and relevant repo instructions are injected, which avoids bloating normal coding context.".to_string(), }, ], notes: vec![ format!("Route: {}", plan.route), format!("Reason: {}", plan.reason), ], }); } None } fn build_pr_feedback_workflow_explanation( plan: &CodexOpenPlan, reference: &CodexResolvedReference, runtime_skills: &[CodexResolveRuntimeSkillSnapshot], ) -> CodexResolveWorkflowExplanation { let fields = parse_reference_fields(&reference.output); let mut commands = Vec::new(); if let Some(command) = reference.command.as_deref() { commands.push(CodexResolveWorkflowCommand { label: "Primary command".to_string(), command: command.to_string(), }); } if let Some(command) = fields.get("cursor reopen") { commands.push(CodexResolveWorkflowCommand { label: "Cursor reopen".to_string(), command: command.clone(), }); } let mut artifacts = Vec::new(); push_workflow_artifact(&mut artifacts, "Workspace", fields.get("workspace"), "path"); push_workflow_artifact( &mut artifacts, "Snapshot markdown", fields.get("snapshot markdown"), "path", ); push_workflow_artifact( &mut artifacts, "Snapshot json", fields.get("snapshot json"), "path", ); push_workflow_artifact(&mut artifacts, "Review plan", fields.get("review plan"), "path"); push_workflow_artifact( &mut artifacts, "Review rules", fields.get("review rules"), "path", ); push_workflow_artifact( &mut artifacts, "Kit system prompt", fields.get("kit system prompt"), "path", ); push_workflow_artifact(&mut artifacts, "Trace ID", fields.get("trace id"), "text"); push_workflow_artifact(&mut artifacts, "PR URL", fields.get("url"), "url"); push_workflow_artifact( &mut artifacts, "PR feedback", fields.get("pr feedback"), "text", ); let skill_note = runtime_skills .iter() .find(|skill| { skill.name == "github" || skill.original_name.as_deref() == Some("github") || skill.name.contains("github") }) .map(|skill| { let mut note = format!( "Runtime skill: {}", skill.original_name .as_deref() .unwrap_or(skill.name.as_str()) ); if let Some(reason) = skill.match_reason.as_deref() { note.push_str(" — "); note.push_str(reason); } note }); let mut notes = vec![ format!("Route: {}", plan.route), format!("Reason: {}", plan.reason), "This explanation is generated by Flow backend code, so myflow stays aligned with the current route behavior instead of duplicating docs in the UI.".to_string(), ]; if let Some(note) = skill_note { notes.push(note); } CodexResolveWorkflowExplanation { id: "pr-feedback".to_string(), title: "GitHub PR review workflow".to_string(), summary: "Flow recognized the prompt as PR review intent, ran the PR feedback pipeline, generated a reusable review packet, injected compact review context into Codex, and loaded the GitHub runtime skill.".to_string(), trigger: "GitHub pull-request URL plus review language like `check`, `comments`, `review`, or `for comments`.".to_string(), generated_by: "flow backend route metadata".to_string(), packet: CodexResolveWorkflowPacket { kind: "pr_feedback".to_string(), compact_summary: "Compact PR review packet with artifact paths, top review items, and a review-plan handoff instead of the full GitHub page.".to_string(), default_view: "Start with the compact PR packet and the generated review plan. Expand snapshot markdown/json only when a review item needs more original context.".to_string(), expansion_rules: vec![ "Read the compact packet first.".to_string(), "Use the generated review plan as the working ledger for item-by-item resolution.".to_string(), "Open snapshot markdown/json only when the packet or review plan is insufficient.".to_string(), ], trace: plan.trace.clone(), validation_plan: { let mut plan = vec![CodexResolveWorkflowValidation { label: "Per-item product validation".to_string(), tier: "targeted".to_string(), detail: "For each review item, run the smallest relevant test, lint, or manual repro in the product repo before marking it resolved.".to_string(), command: None, }]; if let Some(review_plan) = fields.get("review plan") { plan.push(CodexResolveWorkflowValidation { label: "Use the review ledger".to_string(), tier: "operator".to_string(), detail: "Keep the generated review plan up to date as the item-by-item source of truth instead of starting each item from an empty chat.".to_string(), command: Some(review_plan.clone()), }); } if let Some(command) = fields.get("cursor reopen") { plan.push(CodexResolveWorkflowValidation { label: "Reopen the full review surface".to_string(), tier: "operator".to_string(), detail: "Reopen the workspace and review artifacts together when you need the full human + Cursor review loop again.".to_string(), command: Some(command.clone()), }); } plan }, }, commands, artifacts, steps: vec![ CodexResolveWorkflowStep { title: "Route the URL as builtin `pr-feedback`".to_string(), detail: "Flow treats the prompt as review intent instead of a generic web URL, so the route is deterministic and review-specific.".to_string(), }, CodexResolveWorkflowStep { title: "Run the PR feedback pipeline".to_string(), detail: "Flow effectively runs `f pr feedback <url>` and uses `gh` to fetch the PR title, reviews, review comments, and issue comments.".to_string(), }, CodexResolveWorkflowStep { title: "Write the review packet".to_string(), detail: "Flow writes the markdown/json feedback snapshot, the human review plan, the review rules artifact, and the Kit system prompt.".to_string(), }, CodexResolveWorkflowStep { title: "Inject compact review context".to_string(), detail: "Codex receives a compact PR-review block with the generated artifact paths and top feedback items instead of the full GitHub page.".to_string(), }, CodexResolveWorkflowStep { title: "Load the GitHub runtime skill".to_string(), detail: "The runtime state activates the GitHub skill alongside the builtin route so follow-up GitHub CLI work has the right context.".to_string(), }, CodexResolveWorkflowStep { title: "Drive the item-by-item review loop".to_string(), detail: "The generated review packet is what `forge review`, the Flow Review panel, and the Kit follow-up prompts use for the actual resolution workflow.".to_string(), }, ], notes, } } fn parse_reference_fields(output: &str) -> BTreeMap<String, String> { let mut fields = BTreeMap::new(); for line in output.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('[') { continue; } let Some((label, value)) = trimmed.split_once(": ") else { continue; }; if matches!(label, "Summary" | "Top feedback items" | "Plan excerpt") { continue; } let value = value.trim(); if value.is_empty() { continue; } fields.insert(label.to_ascii_lowercase(), value.to_string()); } fields } fn push_workflow_artifact( artifacts: &mut Vec<CodexResolveWorkflowArtifact>, label: &str, value: Option<&String>, kind: &str, ) { if let Some(value) = value { artifacts.push(CodexResolveWorkflowArtifact { label: label.to_string(), value: value.clone(), kind: kind.to_string(), }); } } fn load_runtime_skills_from_plan( plan: &CodexOpenPlan, ) -> Result<Vec<CodexResolveRuntimeSkillSnapshot>> { let Some(path) = plan.runtime_state_path.as_deref() else { return Ok(Vec::new()); }; let raw = fs::read(path).with_context(|| format!("failed to read runtime state {}", path))?; let state: codex_runtime::CodexRuntimeState = serde_json::from_slice(&raw) .with_context(|| format!("failed to decode runtime state {}", path))?; Ok(state .skills .into_iter() .map(|skill| CodexResolveRuntimeSkillSnapshot { name: skill.name, kind: skill.kind, path: skill.path, trigger: skill.trigger, source: skill.source, original_name: skill.original_name, estimated_chars: skill.estimated_chars, match_reason: skill.match_reason, }) .collect()) } const DEFAULT_GLOBAL_CODEX_WRAPPER_BIN: &str = "~/code/flow/scripts/codex-flow-wrapper"; const DEFAULT_GLOBAL_CODEX_HOME_SESSION_PATH: &str = "~/repos/openai/codex"; const DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_NAME: &str = "vercel-labs-skills"; const DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_PATH: &str = "~/repos/vercel-labs/skills"; const DEFAULT_GLOBAL_CODEX_PROMPT_BUDGET: usize = 1200; const DEFAULT_GLOBAL_CODEX_MAX_REFERENCES: usize = 2; const CODEX_SKILL_EVAL_LAUNCHD_LABEL: &str = "dev.nikiv.flow-codex-skill-eval"; #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CodexSkillEvalScheduleStatus { Unsupported, NotInstalled, PlistOnly, Loaded, } impl CodexSkillEvalScheduleStatus { fn as_str(self) -> &'static str { match self { Self::Unsupported => "unsupported", Self::NotInstalled => "not-installed", Self::PlistOnly => "plist-only", Self::Loaded => "loaded", } } fn ready(self) -> bool { matches!(self, Self::Loaded) } } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexDoctorSnapshot { target: String, codex_bin: String, codexd: String, codexd_socket: String, memory_state: String, memory_root: String, memory_db_path: String, memory_events_indexed: usize, memory_facts_indexed: usize, runtime_transport: String, runtime_skills: String, auto_resolve_references: bool, home_session_path: String, prompt_context_budget_chars: usize, max_resolved_references: usize, reference_resolvers: usize, query_cache: String, query_cache_entries_on_disk: usize, skill_eval_events_on_disk: usize, skill_eval_outcomes_on_disk: usize, skill_scorecard_samples: usize, skill_scorecard_entries: usize, skill_scorecard_top: Option<String>, external_skill_candidates: usize, runtime_state_files: usize, runtime_state_files_for_target: usize, skill_eval_schedule: String, learning_state: String, runtime_ready: bool, schedule_ready: bool, learning_ready: bool, warnings: Vec<String>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexSkillsDashboardResponse { pub doctor: CodexDoctorSnapshot, pub skills: codex_runtime::CodexSkillsDashboardSnapshot, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexEvalRouteSnapshot { pub route: String, pub count: usize, pub share: f64, pub avg_context_chars: f64, pub avg_reference_count: f64, pub runtime_activation_rate: f64, pub last_recorded_at_unix: u64, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexEvalSkillSnapshot { pub name: String, pub score: f64, pub sample_size: usize, pub outcome_samples: usize, pub pass_rate: f64, pub normalized_gain: f64, pub avg_context_chars: f64, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexEvalOpportunity { pub severity: String, pub title: String, pub detail: String, pub next_step: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexEvalCommand { pub label: String, pub command: String, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexEvalSnapshot { pub generated_at_unix: u64, pub target_path: String, pub sample_limit: usize, pub recent_events: usize, pub recent_outcomes: usize, pub summary: String, pub quality: CodexEvalQualitySnapshot, pub doctor: CodexDoctorSnapshot, pub top_routes: Vec<CodexEvalRouteSnapshot>, pub top_skills: Vec<CodexEvalSkillSnapshot>, pub opportunities: Vec<CodexEvalOpportunity>, pub commands: Vec<CodexEvalCommand>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexEvalQualitySnapshot { pub status: String, pub summary: String, pub failure_modes: Vec<String>, pub grounded: bool, } fn codex_skill_eval_launchd_plist_path() -> PathBuf { config::expand_path(&format!( "~/Library/LaunchAgents/{}.plist", CODEX_SKILL_EVAL_LAUNCHD_LABEL )) } fn codex_skill_eval_launchd_status() -> CodexSkillEvalScheduleStatus { #[cfg(not(target_os = "macos"))] { CodexSkillEvalScheduleStatus::Unsupported } #[cfg(target_os = "macos")] { let plist = codex_skill_eval_launchd_plist_path(); if !plist.exists() { return CodexSkillEvalScheduleStatus::NotInstalled; } let uid = unsafe { libc::geteuid() }; let domain = format!("gui/{uid}/{CODEX_SKILL_EVAL_LAUNCHD_LABEL}"); match Command::new("launchctl").arg("print").arg(&domain).output() { Ok(output) if output.status.success() => CodexSkillEvalScheduleStatus::Loaded, _ => CodexSkillEvalScheduleStatus::PlistOnly, } } } fn collect_codex_doctor_snapshot(target_path: &Path) -> Result<CodexDoctorSnapshot> { let codex_cfg = load_codex_config_for_path(target_path); let runtime_transport_enabled = codex_runtime_transport_enabled(target_path); let runtime_states = codex_runtime::load_runtime_states()?; let active_runtime_states = runtime_states .iter() .filter(|state| state.target_path == target_path.display().to_string()) .count(); let codex_bin = configured_codex_bin_for_workdir(target_path); let codexd_socket = codexd::socket_path()?; let codexd_running = codexd::is_running(); let memory_stats = codex_memory::stats().ok(); let skill_eval_events = codex_skill_eval::event_count(); let skill_eval_outcomes = codex_skill_eval::outcome_count(); let schedule_status = codex_skill_eval_launchd_status(); let scorecard = codex_skill_eval::load_scorecard(target_path)?; let (skill_scorecard_samples, skill_scorecard_entries, skill_scorecard_top) = scorecard .as_ref() .map(|value| { ( value.samples, value.skills.len(), value .skills .first() .map(|top| format!("{} ({:.2})", top.name, top.score)), ) }) .unwrap_or((0, 0, None)); let discovered_skills = codex_runtime::discover_external_skills(target_path, &codex_cfg)?; let runtime_skills_state = if codex_cfg.runtime_skills.unwrap_or(false) && runtime_transport_enabled { "enabled" } else if codex_cfg.runtime_skills.unwrap_or(false) { "configured-but-inactive" } else { "disabled" }; let runtime_ready = runtime_transport_enabled && runtime_skills_state == "enabled" && codex_cfg.auto_resolve_references.unwrap_or(true); let learning_state = if skill_scorecard_entries > 0 && skill_eval_outcomes > 0 { "grounded" } else if skill_scorecard_entries > 0 { "affinity-only" } else if skill_eval_events > 0 || skill_eval_outcomes > 0 { "warming-up" } else { "dormant" }; let learning_ready = skill_eval_events > 0 && skill_eval_outcomes > 0 && skill_scorecard_entries > 0; let mut warnings = Vec::new(); if !runtime_transport_enabled { warnings.push( "wrapper transport is disabled; Flow is launching plain `codex`, so runtime skills never activate" .to_string(), ); } if runtime_skills_state == "disabled" { warnings.push("runtime skills are disabled in config".to_string()); } if !schedule_status.ready() { warnings.push( "scheduled skill-eval refresh is not loaded; scorecards will only update when you run cron manually" .to_string(), ); } if skill_eval_events == 0 { warnings.push("no Codex route events recorded yet".to_string()); } if skill_eval_outcomes == 0 { warnings.push( "no grounded outcome events recorded yet; scorecards are still affinity-only" .to_string(), ); } if memory_stats.is_none() { warnings.push( "codex memory mirror is unavailable; recent memory and durable sync will stay local-only" .to_string(), ); } let (memory_state, memory_root, memory_db_path, memory_events_indexed, memory_facts_indexed) = if let Some(stats) = memory_stats { ( "ready".to_string(), stats.root_dir, stats.db_path, stats.total_events, stats.total_facts, ) } else { ( "unavailable".to_string(), codex_memory::root_dir().display().to_string(), codex_memory::db_path().display().to_string(), 0, 0, ) }; Ok(CodexDoctorSnapshot { target: target_path.display().to_string(), codex_bin, codexd: if codexd_running { "running".to_string() } else { "stopped".to_string() }, codexd_socket: codexd_socket.display().to_string(), memory_state, memory_root, memory_db_path, memory_events_indexed, memory_facts_indexed, runtime_transport: if runtime_transport_enabled { "enabled".to_string() } else { "disabled".to_string() }, runtime_skills: runtime_skills_state.to_string(), auto_resolve_references: codex_cfg.auto_resolve_references.unwrap_or(true), home_session_path: codex_cfg .home_session_path .as_deref() .map(config::expand_path) .unwrap_or_else(default_codex_connect_path) .display() .to_string(), prompt_context_budget_chars: effective_prompt_context_budget_chars(&codex_cfg, false), max_resolved_references: effective_max_resolved_references(&codex_cfg), reference_resolvers: codex_cfg.reference_resolvers.len(), query_cache: if codex_query_cache_disabled() { "disabled".to_string() } else { "enabled".to_string() }, query_cache_entries_on_disk: codex_query_cache_entry_count(), skill_eval_events_on_disk: skill_eval_events, skill_eval_outcomes_on_disk: skill_eval_outcomes, skill_scorecard_samples, skill_scorecard_entries, skill_scorecard_top, external_skill_candidates: discovered_skills.len(), runtime_state_files: runtime_states.len(), runtime_state_files_for_target: active_runtime_states, skill_eval_schedule: schedule_status.as_str().to_string(), learning_state: learning_state.to_string(), runtime_ready, schedule_ready: schedule_status.ready(), learning_ready, warnings, }) } pub fn codex_skills_dashboard_snapshot( target_path: &Path, recent_limit: usize, ) -> Result<CodexSkillsDashboardResponse> { let codex_cfg = load_codex_config_for_path(target_path); Ok(CodexSkillsDashboardResponse { doctor: collect_codex_doctor_snapshot(target_path)?, skills: codex_runtime::dashboard_snapshot(target_path, &codex_cfg, recent_limit)?, }) } pub fn codex_skill_source_sync( target_path: &Path, selected_skills: &[String], force: bool, ) -> Result<usize> { let codex_cfg = load_codex_config_for_path(target_path); codex_runtime::sync_external_skills(target_path, &codex_cfg, selected_skills, force) } fn print_codex_doctor(snapshot: &CodexDoctorSnapshot) { println!("# codex doctor"); println!("target: {}", snapshot.target); println!("codex_bin: {}", snapshot.codex_bin); println!("codexd: {}", snapshot.codexd); println!("codexd_socket: {}", snapshot.codexd_socket); println!("memory_state: {}", snapshot.memory_state); println!("memory_root: {}", snapshot.memory_root); println!("memory_db_path: {}", snapshot.memory_db_path); println!("memory_events_indexed: {}", snapshot.memory_events_indexed); println!("memory_facts_indexed: {}", snapshot.memory_facts_indexed); println!("runtime_transport: {}", snapshot.runtime_transport); println!("runtime_skills: {}", snapshot.runtime_skills); println!( "auto_resolve_references: {}", snapshot.auto_resolve_references ); println!("home_session_path: {}", snapshot.home_session_path); println!( "prompt_context_budget_chars: {}", snapshot.prompt_context_budget_chars ); println!( "max_resolved_references: {}", snapshot.max_resolved_references ); println!("reference_resolvers: {}", snapshot.reference_resolvers); println!("query_cache: {}", snapshot.query_cache); println!( "query_cache_entries_on_disk: {}", snapshot.query_cache_entries_on_disk ); println!( "skill_eval_events_on_disk: {}", snapshot.skill_eval_events_on_disk ); println!( "skill_eval_outcomes_on_disk: {}", snapshot.skill_eval_outcomes_on_disk ); println!( "skill_scorecard_samples: {}", snapshot.skill_scorecard_samples ); println!( "skill_scorecard_entries: {}", snapshot.skill_scorecard_entries ); if let Some(top) = &snapshot.skill_scorecard_top { println!("skill_scorecard_top: {}", top); } println!( "external_skill_candidates: {}", snapshot.external_skill_candidates ); println!("runtime_state_files: {}", snapshot.runtime_state_files); println!( "runtime_state_files_for_target: {}", snapshot.runtime_state_files_for_target ); println!("skill_eval_schedule: {}", snapshot.skill_eval_schedule); println!("learning_state: {}", snapshot.learning_state); println!("runtime_ready: {}", snapshot.runtime_ready); println!("schedule_ready: {}", snapshot.schedule_ready); println!("learning_ready: {}", snapshot.learning_ready); if !snapshot.warnings.is_empty() { println!("warnings: {}", snapshot.warnings.len()); for warning in &snapshot.warnings { println!("- {}", warning); } } } fn assert_codex_doctor( snapshot: &CodexDoctorSnapshot, assert_runtime: bool, assert_schedule: bool, assert_learning: bool, assert_autonomous: bool, ) -> Result<()> { let mut failures = Vec::new(); let require_runtime = assert_runtime || assert_autonomous; let require_schedule = assert_schedule || assert_autonomous; let require_learning = assert_learning || assert_autonomous; if require_runtime { if snapshot.runtime_transport != "enabled" { failures.push( "runtime transport is disabled; set [options].codex_bin to the Flow wrapper" .to_string(), ); } if snapshot.runtime_skills != "enabled" { failures.push( "runtime skills are not active; enable [codex].runtime_skills and use the Flow wrapper" .to_string(), ); } if !snapshot.auto_resolve_references { failures.push("auto_resolve_references is disabled".to_string()); } } if require_schedule && !snapshot.schedule_ready { failures.push(format!( "scheduled skill-eval refresh is {}; install/load the launchd agent", snapshot.skill_eval_schedule )); } if require_learning { if snapshot.skill_eval_events_on_disk == 0 { failures.push("no Codex route events recorded yet".to_string()); } if snapshot.skill_scorecard_entries == 0 { failures.push("no skill scorecard entries built yet".to_string()); } if snapshot.skill_eval_outcomes_on_disk == 0 { failures.push( "no grounded skill outcome events recorded yet; the system is still affinity-only" .to_string(), ); } } if failures.is_empty() { return Ok(()); } bail!( "codex doctor assertion failed:\n- {}\nnext: run `f codex enable-global --full`, then exercise `f codex open ...` or `f ai codex new` through Flow until outcomes appear", failures.join("\n- ") ) } fn codexd_learning_refresh_interval_secs() -> u64 { std::env::var("FLOW_CODEXD_LEARNING_REFRESH_SECS") .ok() .and_then(|value| value.parse::<u64>().ok()) .map(|value| value.clamp(60, 3600)) .unwrap_or(900) } fn codexd_learning_refresh_state() -> &'static Mutex<u64> { static STATE: OnceLock<Mutex<u64>> = OnceLock::new(); STATE.get_or_init(|| Mutex::new(0)) } pub(crate) fn maybe_run_codex_learning_refresh() -> Result<usize> { let interval_secs = codexd_learning_refresh_interval_secs(); let now = unix_now_secs(); { let mut guard = codexd_learning_refresh_state() .lock() .expect("codexd learning refresh mutex poisoned"); if now.saturating_sub(*guard) < interval_secs { return Ok(0); } *guard = now; } let _ = codex_memory::sync_from_skill_eval_logs(400); let targets = codex_skill_eval::recent_targets(400, 10, 168)?; let mut refreshed = 0usize; for target in targets { if !target.exists() { continue; } codex_skill_eval::rebuild_scorecard(&target, 200)?; refreshed += 1; } Ok(refreshed) } fn codex_eval_commands(target_path: &Path) -> Vec<CodexEvalCommand> { let target = target_path.display().to_string(); vec![ CodexEvalCommand { label: "Doctor".to_string(), command: format!("f codex doctor --path {}", target), }, CodexEvalCommand { label: "Autonomous readiness".to_string(), command: format!("f codex doctor --path {} --assert-autonomous", target), }, CodexEvalCommand { label: "Skill scorecard".to_string(), command: format!("f codex skill-eval show --path {}", target), }, CodexEvalCommand { label: "Recent memory".to_string(), command: format!("f codex memory recent --path {} --limit 12", target), }, CodexEvalCommand { label: "Daemon status".to_string(), command: "f codex daemon status".to_string(), }, ] } fn codex_eval_failure_modes(doctor: &CodexDoctorSnapshot) -> Vec<String> { let mut failure_modes = Vec::new(); if doctor.runtime_transport != "enabled" { failure_modes.push("wrapper transport disabled".to_string()); } if doctor.runtime_skills != "enabled" { failure_modes.push(format!("runtime skills {}", doctor.runtime_skills)); } if doctor.memory_state != "ready" { failure_modes.push("codex memory unavailable".to_string()); } failure_modes } fn build_codex_eval_quality( doctor: &CodexDoctorSnapshot, recent_events: usize, recent_outcomes: usize, ) -> CodexEvalQualitySnapshot { let failure_modes = codex_eval_failure_modes(doctor); let status = if failure_modes.is_empty() { "valid" } else { "erroneous" }; let grounded = recent_outcomes > 0 && doctor.learning_ready; let summary = if status == "erroneous" { format!( "Current target health is erroneous for workflow measurement because Flow is not fully controlling Codex here: {}.", failure_modes.join(", ") ) } else if grounded { "Current target health is valid and grounded outcome samples are available.".to_string() } else if recent_events == 0 { "Current target health is valid, but there are no recorded Flow-routed Codex events here yet.".to_string() } else { "Current target health is valid, but measurements are still warming up because there are no grounded outcome samples yet.".to_string() }; CodexEvalQualitySnapshot { status: status.to_string(), summary, failure_modes, grounded, } } fn build_codex_eval_summary( doctor: &CodexDoctorSnapshot, events: usize, outcomes: usize, top_route: Option<&CodexEvalRouteSnapshot>, top_skill: Option<&CodexEvalSkillSnapshot>, ) -> String { if events == 0 { return "No Flow-routed Codex events recorded yet for this target.".to_string(); } if !doctor.runtime_ready { return format!( "Flow is recording usage, but the Codex wrapper/runtime path is not fully active yet. Recent launches: {}.", events ); } if outcomes == 0 { return format!( "Flow is recording {} recent Codex launches here, but there are no grounded outcome samples yet, so learning is still affinity-only.", events ); } let route = top_route .map(|value| value.route.as_str()) .unwrap_or("unknown"); let skill = top_skill .map(|value| value.name.as_str()) .unwrap_or("none"); format!( "Runtime is ready and grounded learning is active. Recent launches: {}, grounded outcomes: {}, top route: {}, top skill: {}.", events, outcomes, route, skill ) } fn build_codex_eval_opportunities( doctor: &CodexDoctorSnapshot, recent_events: usize, recent_outcomes: usize, routes: &[CodexEvalRouteSnapshot], skills: &[CodexEvalSkillSnapshot], ) -> Vec<CodexEvalOpportunity> { let mut opportunities = Vec::new(); if doctor.runtime_transport != "enabled" || doctor.runtime_skills != "enabled" { opportunities.push(CodexEvalOpportunity { severity: "high".to_string(), title: "Wrapper/runtime path is not fully active".to_string(), detail: "Flow cannot reliably improve Codex usage until prompts enter through the Flow wrapper and runtime skills are active.".to_string(), next_step: "Run `f codex enable-global --full`, then start Codex through `j`, `L`, or `f codex open ...`.".to_string(), }); } if doctor.codexd != "running" { opportunities.push(CodexEvalOpportunity { severity: "medium".to_string(), title: "codexd is not running".to_string(), detail: "Recent-session hydration and background completion reconciliation stay cold when the Flow Codex daemon is stopped.".to_string(), next_step: "Run `f codex daemon start` to keep session recovery and eval maintenance warm.".to_string(), }); } if recent_events > 0 && recent_outcomes == 0 { opportunities.push(CodexEvalOpportunity { severity: "high".to_string(), title: "No grounded outcome samples for this target yet".to_string(), detail: "Flow is learning from route history here, but it does not yet have target-scoped success/failure outcomes to tell whether runtime skills are actually helping.".to_string(), next_step: "Exercise workflows that emit outcomes through Flow for this repo/path, then rerun `f codex eval --path ...` or `f codex skill-eval show --path ...`.".to_string(), }); } else if recent_outcomes > 0 && doctor.skill_scorecard_entries == 0 { opportunities.push(CodexEvalOpportunity { severity: "medium".to_string(), title: "Scorecard has not been built yet".to_string(), detail: "Outcome data exists, but there is no repo-scoped scorecard summarizing which runtime skills are helping.".to_string(), next_step: "Run `f codex skill-eval run --path ...` once, or let codexd refresh it in the background.".to_string(), }); } if let Some(skill) = skills.first() && skill.outcome_samples == 0 { opportunities.push(CodexEvalOpportunity { severity: "medium".to_string(), title: format!("Top skill `{}` is still affinity-only", skill.name), detail: "This skill is triggering often enough to score highly, but Flow has not seen grounded success outcomes for it yet.".to_string(), next_step: "Add or reuse a deterministic success marker for the workflow that uses this skill, so outcomes get logged.".to_string(), }); } if let Some(skill) = skills .iter() .find(|skill| skill.sample_size >= 3 && skill.outcome_samples >= 2 && skill.pass_rate < 0.55) { opportunities.push(CodexEvalOpportunity { severity: "medium".to_string(), title: format!("Skill `{}` is underperforming", skill.name), detail: format!( "It has {} grounded outcome sample(s) with pass rate {:.2}.", skill.outcome_samples, skill.pass_rate ), next_step: "Inspect the skill trigger, gotchas, and injected context. Trim or sharpen it before adding more automation.".to_string(), }); } if let Some(route) = routes .iter() .find(|route| route.count >= 3 && route.avg_context_chars > 1800.0) { opportunities.push(CodexEvalOpportunity { severity: "low".to_string(), title: format!("Route `{}` is context-heavy", route.route), detail: format!( "Average injected context is {:.0} chars across {} recent launch(es).", route.avg_context_chars, route.count ), next_step: "Trim the workflow packet or sharpen the reference unrolling so the route stays compact.".to_string(), }); } if let Some(route) = routes.first() && route.route == "new-plain" && route.share >= 0.7 && doctor.external_skill_candidates > 0 { opportunities.push(CodexEvalOpportunity { severity: "low".to_string(), title: "Most launches are still plain prompts".to_string(), detail: "That is not necessarily bad, but it suggests the repo has more opportunity for explicit workflow routes or sharper runtime skill triggers.".to_string(), next_step: "Inspect common prompts in the recent events and decide whether one should become a first-class workflow or skill trigger.".to_string(), }); } if !doctor.schedule_ready && doctor.codexd != "running" { opportunities.push(CodexEvalOpportunity { severity: "low".to_string(), title: "No background refresh is active".to_string(), detail: "Scorecards only refresh when you run commands manually if neither launchd nor codexd is keeping the learning data warm.".to_string(), next_step: "Install the launchd refresher with `f codex enable-global --full` or keep `codexd` running.".to_string(), }); } if opportunities.is_empty() { opportunities.push(CodexEvalOpportunity { severity: "info".to_string(), title: "No immediate weaknesses detected".to_string(), detail: "Flow runtime, grounding, and recent skill usage look healthy for this repo/path.".to_string(), next_step: "Keep using `f codex eval --path ...` after workflow changes to catch regressions early.".to_string(), }); } opportunities } pub fn codex_eval_snapshot(target_path: &Path, limit: usize) -> Result<CodexEvalSnapshot> { let _ = reconcile_pending_codex_quick_launches(limit.max(64)); let doctor = collect_codex_doctor_snapshot(target_path)?; let events = codex_skill_eval::load_events(Some(target_path), limit)?; let outcomes = codex_skill_eval::load_outcomes(Some(target_path), limit)?; let latest_event_at = events.first().map(|event| event.recorded_at_unix).unwrap_or(0); let scorecard = match codex_skill_eval::load_scorecard(target_path)? { Some(scorecard) if scorecard.generated_at_unix >= latest_event_at => scorecard, _ => codex_skill_eval::rebuild_scorecard(target_path, limit.max(200))?, }; #[derive(Default)] struct RouteAggregate { count: usize, total_context_chars: usize, total_reference_count: usize, runtime_activations: usize, last_recorded_at_unix: u64, } let mut route_aggregates: BTreeMap<String, RouteAggregate> = BTreeMap::new(); for event in &events { let entry = route_aggregates.entry(event.route.clone()).or_default(); entry.count += 1; entry.total_context_chars += event.injected_context_chars; entry.total_reference_count += event.reference_count; if !event.runtime_skills.is_empty() { entry.runtime_activations += 1; } entry.last_recorded_at_unix = entry.last_recorded_at_unix.max(event.recorded_at_unix); } let event_count = events.len().max(1) as f64; let mut top_routes = route_aggregates .into_iter() .map(|(route, agg)| CodexEvalRouteSnapshot { route, count: agg.count, share: agg.count as f64 / event_count, avg_context_chars: agg.total_context_chars as f64 / agg.count as f64, avg_reference_count: agg.total_reference_count as f64 / agg.count as f64, runtime_activation_rate: agg.runtime_activations as f64 / agg.count as f64, last_recorded_at_unix: agg.last_recorded_at_unix, }) .collect::<Vec<_>>(); top_routes.sort_by(|a, b| { b.count .cmp(&a.count) .then_with(|| b.last_recorded_at_unix.cmp(&a.last_recorded_at_unix)) }); top_routes.truncate(6); let mut top_skills = scorecard .skills .iter() .map(|skill| CodexEvalSkillSnapshot { name: skill.name.clone(), score: skill.score, sample_size: skill.sample_size, outcome_samples: skill.outcome_samples, pass_rate: skill.pass_rate, normalized_gain: skill.normalized_gain, avg_context_chars: skill.avg_context_chars, }) .collect::<Vec<_>>(); top_skills.truncate(6); let summary = build_codex_eval_summary( &doctor, events.len(), outcomes.len(), top_routes.first(), top_skills.first(), ); let quality = build_codex_eval_quality(&doctor, events.len(), outcomes.len()); let opportunities = build_codex_eval_opportunities( &doctor, events.len(), outcomes.len(), &top_routes, &top_skills, ); Ok(CodexEvalSnapshot { generated_at_unix: unix_now_secs(), target_path: target_path.display().to_string(), sample_limit: limit, recent_events: events.len(), recent_outcomes: outcomes.len(), summary, quality, doctor, top_routes, top_skills, opportunities, commands: codex_eval_commands(target_path), }) } fn print_codex_eval(snapshot: &CodexEvalSnapshot) { println!("# codex eval"); println!("target: {}", snapshot.target_path); println!("summary: {}", snapshot.summary); println!("quality: {}", snapshot.quality.status); println!("quality_summary: {}", snapshot.quality.summary); println!("sample_limit: {}", snapshot.sample_limit); println!("recent_events: {}", snapshot.recent_events); println!("recent_outcomes: {}", snapshot.recent_outcomes); println!("runtime_ready: {}", snapshot.doctor.runtime_ready); println!("learning_ready: {}", snapshot.doctor.learning_ready); println!("codexd: {}", snapshot.doctor.codexd); if !snapshot.quality.failure_modes.is_empty() { println!("failure_modes:"); for mode in &snapshot.quality.failure_modes { println!("- {}", mode); } } if !snapshot.top_routes.is_empty() { println!("routes:"); for route in &snapshot.top_routes { println!( "- {} | count {} | share {:.0}% | ctx {:.0} chars | refs {:.1} | runtime {:.0}%", route.route, route.count, route.share * 100.0, route.avg_context_chars, route.avg_reference_count, route.runtime_activation_rate * 100.0 ); } } if !snapshot.top_skills.is_empty() { println!("skills:"); for skill in &snapshot.top_skills { println!( "- {} | score {:.2} | samples {} | outcomes {} | pass {:.2} | gain {:.3} | ctx {:.0} chars", skill.name, skill.score, skill.sample_size, skill.outcome_samples, skill.pass_rate, skill.normalized_gain, skill.avg_context_chars ); } } if !snapshot.opportunities.is_empty() { println!("opportunities:"); for item in &snapshot.opportunities { println!("- [{}] {} — {}", item.severity, item.title, item.detail); println!(" next: {}", item.next_step); } } if !snapshot.commands.is_empty() { println!("commands:"); for command in &snapshot.commands { println!("- {}: {}", command.label, command.command); } } } fn codex_eval( path: Option<String>, limit: usize, json: bool, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("eval is only supported for Codex sessions; use `f codex eval`"); } let target_path = resolve_session_target_path(path.as_deref())?; let snapshot = codex_eval_snapshot(&target_path, limit.clamp(20, 1000))?; if json { println!( "{}", serde_json::to_string_pretty(&snapshot) .context("failed to encode codex eval JSON")? ); } else { print_codex_eval(&snapshot); } Ok(()) } fn parse_global_flow_toml(path: &Path) -> Result<toml::value::Table> { if !path.exists() { return Ok(toml::value::Table::new()); } let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; if content.trim().is_empty() { return Ok(toml::value::Table::new()); } let value: TomlValue = toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?; value .as_table() .cloned() .ok_or_else(|| anyhow::anyhow!("global flow config must be a TOML table")) } fn ensure_toml_table<'a>( root: &'a mut toml::value::Table, key: &str, ) -> Result<&'a mut toml::value::Table> { let needs_insert = !matches!(root.get(key), Some(TomlValue::Table(_))); if needs_insert { if root.contains_key(key) { bail!("expected [{}] to be a table in global flow config", key); } root.insert(key.to_string(), TomlValue::Table(toml::value::Table::new())); } root.get_mut(key) .and_then(TomlValue::as_table_mut) .ok_or_else(|| anyhow::anyhow!("expected [{}] to be a table in global flow config", key)) } fn write_string_atomically(path: &Path, content: &str) -> Result<()> { let parent = path .parent() .ok_or_else(|| anyhow::anyhow!("missing parent for {}", path.display()))?; fs::create_dir_all(parent)?; let temp = parent.join(format!( ".{}.tmp-{}-{}", path.file_name() .and_then(|value| value.to_str()) .unwrap_or("flow.toml"), std::process::id(), unix_now_secs() )); fs::write(&temp, content).with_context(|| format!("failed to write {}", temp.display()))?; fs::rename(&temp, path).with_context(|| format!("failed to replace {}", path.display()))?; Ok(()) } fn upsert_global_codex_config(path: &Path) -> Result<(String, bool, bool, bool)> { let mut root = parse_global_flow_toml(path)?; let created = !path.exists(); let wrapper_path = config::expand_path(DEFAULT_GLOBAL_CODEX_WRAPPER_BIN); if !wrapper_path.exists() { bail!( "Flow Codex wrapper is missing at {}; build or sync Flow first", wrapper_path.display() ); } let codex = ensure_toml_table(&mut root, "codex")?; codex.insert("runtime_skills".to_string(), TomlValue::Boolean(true)); codex.insert( "auto_resolve_references".to_string(), TomlValue::Boolean(true), ); codex .entry("home_session_path".to_string()) .or_insert_with(|| TomlValue::String(DEFAULT_GLOBAL_CODEX_HOME_SESSION_PATH.to_string())); codex .entry("prompt_context_budget_chars".to_string()) .or_insert_with(|| TomlValue::Integer(DEFAULT_GLOBAL_CODEX_PROMPT_BUDGET as i64)); codex .entry("max_resolved_references".to_string()) .or_insert_with(|| TomlValue::Integer(DEFAULT_GLOBAL_CODEX_MAX_REFERENCES as i64)); let skill_source_root = config::expand_path(DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_PATH); let skill_source_available = skill_source_root.exists(); let mut skill_source_added = false; if skill_source_available { let entry = codex .entry("skill_source".to_string()) .or_insert_with(|| TomlValue::Array(Vec::new())); let array = entry .as_array_mut() .ok_or_else(|| anyhow::anyhow!("[codex].skill_source must be an array"))?; let exists = array.iter().any(|value| { let Some(table) = value.as_table() else { return false; }; table .get("name") .and_then(TomlValue::as_str) .map(|name| name == DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_NAME) .unwrap_or(false) || table .get("path") .and_then(TomlValue::as_str) .map(|value| config::expand_path(value) == skill_source_root) .unwrap_or(false) }); if !exists { let mut source = toml::value::Table::new(); source.insert( "name".to_string(), TomlValue::String(DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_NAME.to_string()), ); source.insert( "path".to_string(), TomlValue::String(DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_PATH.to_string()), ); source.insert("enabled".to_string(), TomlValue::Boolean(true)); array.push(TomlValue::Table(source)); skill_source_added = true; } } let options = ensure_toml_table(&mut root, "options")?; options.insert( "codex_bin".to_string(), TomlValue::String(DEFAULT_GLOBAL_CODEX_WRAPPER_BIN.to_string()), ); let rendered = toml::to_string_pretty(&TomlValue::Table(root)) .context("failed to render global flow config")?; Ok(( rendered, created, skill_source_added, skill_source_available, )) } fn install_codex_skill_eval_launchd( current_exe: &Path, minutes: usize, limit: usize, max_targets: usize, within_hours: u64, dry_run: bool, ) -> Result<String> { let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("scripts") .join("codex-skill-eval-launchd.py"); let mut command = Command::new("python3"); command .arg(script) .arg("install") .arg("--minutes") .arg(minutes.to_string()) .arg("--limit") .arg(limit.to_string()) .arg("--max-targets") .arg(max_targets.to_string()) .arg("--within-hours") .arg(within_hours.to_string()); if dry_run { command.arg("--dry-run"); } command.env("FLOW_CODEX_SKILL_EVAL_F_BIN", current_exe); let output = command .output() .context("failed to run codex skill-eval launchd installer")?; if !output.status.success() { bail!( "codex skill-eval launchd install failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn codex_enable_global( dry_run: bool, install_launchd: bool, start_daemon: bool, sync_skills: bool, full: bool, minutes: usize, limit: usize, max_targets: usize, within_hours: u64, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("enable-global is only supported for Codex sessions; use `f codex enable-global`"); } let install_launchd = install_launchd || full; let start_daemon = start_daemon || full; let sync_skills = sync_skills || full; let config_path = config::default_config_path(); let (rendered, created, skill_source_added, skill_source_available) = upsert_global_codex_config(&config_path)?; if dry_run { println!("# codex enable-global"); println!("config_path: {}", config_path.display()); println!("config_created: {}", created); println!("skill_source_available: {}", skill_source_available); println!("skill_source_added: {}", skill_source_added); if install_launchd { let preview = install_codex_skill_eval_launchd( &env::current_exe().context("failed to resolve current flow executable")?, minutes, limit, max_targets, within_hours, true, )?; println!(); println!("{}", preview); } println!(); print!("{}", rendered); return Ok(()); } let global_dir = config::ensure_global_config_dir()?; write_string_atomically(&config_path, &rendered)?; println!("Updated global Flow config: {}", config_path.display()); if created { println!("Created {}", global_dir.display()); } println!( "Enabled global Codex wrapper/runtime transport via {}", DEFAULT_GLOBAL_CODEX_WRAPPER_BIN ); if skill_source_available { if skill_source_added { println!( "Registered external skill source: {}", DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_PATH ); } else { println!( "External skill source already configured: {}", DEFAULT_GLOBAL_CODEX_SKILL_SOURCE_PATH ); } } if install_launchd { let launchd_output = install_codex_skill_eval_launchd( &env::current_exe().context("failed to resolve current flow executable")?, minutes, limit, max_targets, within_hours, false, )?; if !launchd_output.is_empty() { println!("{}", launchd_output); } } if start_daemon { codexd::start()?; } if sync_skills { let target_path = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let codex_cfg = load_codex_config_for_path(&target_path); let installed = codex_runtime::sync_external_skills(&target_path, &codex_cfg, &[], false)?; println!( "Synced {} external Codex skill(s) into ~/.codex/skills.", installed ); } let verify_target = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let snapshot = collect_codex_doctor_snapshot(&verify_target)?; assert_codex_doctor(&snapshot, true, install_launchd, false, false)?; println!(); print_codex_doctor(&snapshot); Ok(()) } fn codex_doctor( path: Option<String>, assert_runtime: bool, assert_schedule: bool, assert_learning: bool, assert_autonomous: bool, json_output: bool, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("doctor is only supported for Codex sessions; use `f codex doctor`"); } let target_path = resolve_session_target_path(path.as_deref())?; let snapshot = collect_codex_doctor_snapshot(&target_path)?; if json_output { println!( "{}", serde_json::to_string_pretty(&snapshot) .context("failed to encode codex doctor JSON")? ); } else { print_codex_doctor(&snapshot); } assert_codex_doctor( &snapshot, assert_runtime, assert_schedule, assert_learning, assert_autonomous, )?; Ok(()) } #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexQuickLaunchEvent { version: u8, launch_id: String, recorded_at_unix: u64, mode: String, cwd: String, daemon: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexQuickLaunchHydration { version: u8, launch_id: String, hydrated_at_unix: u64, target_path: String, session_id: String, query: String, prompt_recorded_at_unix: u64, } fn codex_quick_launch_log_path() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()? .join("codex") .join("quick-launches.jsonl")) } fn codex_quick_launch_hydrations_path() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()? .join("codex") .join("quick-launches-hydrated.jsonl")) } fn log_codex_quick_launch_event(event: &CodexQuickLaunchEvent) -> Result<()> { let path = codex_quick_launch_log_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("failed to open {}", path.display()))?; serde_json::to_writer(&mut file, event).context("failed to encode quick launch event")?; file.write_all(b"\n") .context("failed to terminate quick launch event")?; Ok(()) } fn log_codex_quick_launch_hydration(hydration: &CodexQuickLaunchHydration) -> Result<()> { let path = codex_quick_launch_hydrations_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("failed to open {}", path.display()))?; serde_json::to_writer(&mut file, hydration) .context("failed to encode quick launch hydration")?; file.write_all(b"\n") .context("failed to terminate quick launch hydration")?; Ok(()) } fn load_recent_codex_quick_launches(limit: usize) -> Result<Vec<CodexQuickLaunchEvent>> { let path = codex_quick_launch_log_path()?; if !path.exists() || limit == 0 { return Ok(Vec::new()); } let raw = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let mut launches = raw .lines() .rev() .filter_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() { return None; } serde_json::from_str::<CodexQuickLaunchEvent>(trimmed).ok() }) .take(limit) .collect::<Vec<_>>(); launches.sort_by_key(|launch| launch.recorded_at_unix); Ok(launches) } fn load_hydrated_codex_quick_launch_ids() -> Result<BTreeSet<String>> { let path = codex_quick_launch_hydrations_path()?; if !path.exists() { return Ok(BTreeSet::new()); } let raw = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; Ok(raw .lines() .filter_map(|line| serde_json::from_str::<CodexQuickLaunchHydration>(line.trim()).ok()) .map(|hydration| hydration.launch_id) .collect()) } fn parse_rfc3339_to_unix(value: &str) -> Option<u64> { chrono::DateTime::parse_from_rfc3339(value) .ok() .and_then(|dt| u64::try_from(dt.timestamp()).ok()) } fn read_codex_first_user_message_since( session_file: &PathBuf, since_unix: u64, ) -> Result<Option<(String, u64)>> { let mut first: Option<(String, u64)> = None; for_each_nonempty_jsonl_line(session_file, |line| { let entry: CodexEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; let Some((role, text)) = extract_codex_message(&entry) else { return; }; if role != "user" || text.trim().is_empty() { return; } let Some(cleaned) = codex_text::sanitize_codex_query_text(&text) else { return; }; let Some(ts) = extract_codex_timestamp(&entry).and_then(|value| parse_rfc3339_to_unix(&value)) else { return; }; if ts < since_unix { return; } if first .as_ref() .map(|(_, current)| ts < *current) .unwrap_or(true) { first = Some((cleaned, ts)); } })?; Ok(first) } fn file_modified_unix(path: &Path) -> Option<u64> { fs::metadata(path) .ok()? .modified() .ok()? .duration_since(UNIX_EPOCH) .ok() .map(|value| value.as_secs()) } fn read_codex_session_completion_snapshot( session_file: &Path, ) -> Result<Option<CodexSessionCompletionSnapshot>> { let file_modified_unix = file_modified_unix(session_file).unwrap_or(0); let mut snapshot = CodexSessionCompletionSnapshot { last_role: None, last_user_message: None, last_user_at_unix: None, last_assistant_message: None, last_assistant_at_unix: None, file_modified_unix, }; for_each_nonempty_jsonl_line(session_file, |line| { let entry: CodexEntry = match crate::json_parse::parse_json_line(line) { Ok(v) => v, Err(_) => return, }; let Some((role, text)) = extract_codex_message(&entry) else { return; }; let Some(ts) = extract_codex_timestamp(&entry).and_then(|value| parse_rfc3339_to_unix(&value)) else { return; }; snapshot.last_role = Some(role.clone()); match role.as_str() { "user" => { snapshot.last_user_message = Some(text); snapshot.last_user_at_unix = Some(ts); } "assistant" => { snapshot.last_assistant_message = Some(text); snapshot.last_assistant_at_unix = Some(ts); } _ => {} } })?; if snapshot.last_role.is_none() { return Ok(None); } Ok(Some(snapshot)) } fn assistant_completion_summary(text: &str) -> Option<String> { let cleaned = codex_text::sanitize_codex_memory_rollout_text(text)?; let first_line = cleaned .lines() .map(str::trim) .find(|line| !line.is_empty())?; let summary = first_line .trim_start_matches(|ch: char| matches!(ch, '-' | '*' | ' ')) .trim(); if summary.is_empty() { return None; } let lower = summary.to_ascii_lowercase(); if matches!( lower.as_str(), "done" | "done." | "completed" | "completed." | "implemented" | "implemented." | "fixed" | "fixed." | "it's in." | "it’s in." | "all set." ) { return None; } Some(summary.to_string()) } fn select_codex_session_completion_summary( row: &CodexRecoverRow, snapshot: &CodexSessionCompletionSnapshot, ) -> String { snapshot .last_assistant_message .as_deref() .and_then(assistant_completion_summary) .or_else(|| { snapshot .last_user_message .as_deref() .and_then(codex_text::sanitize_codex_query_text) }) .or_else(|| { row.first_user_message .as_deref() .and_then(codex_text::sanitize_codex_query_text) }) .or_else(|| row.title.as_deref().map(str::trim).map(str::to_string)) .unwrap_or_else(|| "completed session turn".to_string()) } fn build_codex_session_completion_event( row: &CodexRecoverRow, snapshot: &CodexSessionCompletionSnapshot, ) -> activity_log::ActivityEvent { let mut event = activity_log::ActivityEvent::done( "codex.done", truncate_recover_text(&select_codex_session_completion_summary(row, snapshot)), ); event.target_path = Some(row.cwd.clone()); event.launch_path = Some(row.cwd.clone()); event.session_id = Some(row.id.clone()); event.source = Some("codex-session-completion".to_string()); event.dedupe_key = snapshot .last_assistant_at_unix .map(|value| format!("codex:done:{}:{value}", row.id)); event } fn read_codex_turn_patch_changes( session_file: &Path, since_unix: u64, until_unix: u64, session_cwd: &str, ) -> Result<Vec<CodexTurnPatchChange>> { let mut changes: Vec<CodexTurnPatchChange> = Vec::new(); for_each_nonempty_jsonl_line(session_file, |line| { let entry: CodexEntry = match crate::json_parse::parse_json_line(line) { Ok(value) => value, Err(_) => return, }; let Some(ts) = extract_codex_timestamp(&entry).and_then(|value| parse_rfc3339_to_unix(&value)) else { return; }; if ts < since_unix || ts > until_unix { return; } let Some(payload) = entry.payload.as_ref() else { return; }; if entry.entry_type.as_deref() != Some("response_item") { return; } if payload.get("type").and_then(|value| value.as_str()) != Some("custom_tool_call") { return; } if payload.get("status").and_then(|value| value.as_str()) != Some("completed") { return; } if payload.get("name").and_then(|value| value.as_str()) != Some("apply_patch") { return; } let Some(input) = payload.get("input").and_then(|value| value.as_str()) else { return; }; for change in parse_apply_patch_changes(input, session_cwd) { if let Some(existing) = changes.iter_mut().find(|item| item.path == change.path) { if !existing.patch.is_empty() && !change.patch.is_empty() { existing.patch.push('\n'); } existing.patch.push_str(&change.patch); if existing.action != change.action { existing.action = "update".to_string(); } } else { changes.push(change); } } })?; Ok(changes) } fn parse_apply_patch_changes(input: &str, session_cwd: &str) -> Vec<CodexTurnPatchChange> { let mut changes = Vec::new(); let mut current_path: Option<String> = None; let mut current_action = String::new(); let mut current_patch = String::new(); let flush_current = |changes: &mut Vec<CodexTurnPatchChange>, current_path: &mut Option<String>, current_action: &mut String, current_patch: &mut String| { let Some(path) = current_path.take() else { return; }; changes.push(CodexTurnPatchChange { path, action: std::mem::take(current_action), patch: current_patch.trim().to_string(), }); current_patch.clear(); }; for line in input.lines() { let header = if let Some(path) = line.strip_prefix("*** Update File: ") { Some(("update", path)) } else if let Some(path) = line.strip_prefix("*** Add File: ") { Some(("add", path)) } else if let Some(path) = line.strip_prefix("*** Delete File: ") { Some(("delete", path)) } else { None }; if let Some((action, path)) = header { flush_current( &mut changes, &mut current_path, &mut current_action, &mut current_patch, ); current_action = action.to_string(); current_path = Some(resolve_patch_path(path, session_cwd)); continue; } if let Some(path) = line.strip_prefix("*** Move to: ") { current_path = Some(resolve_patch_path(path, session_cwd)); continue; } if current_path.is_some() { current_patch.push_str(line); current_patch.push('\n'); } } flush_current( &mut changes, &mut current_path, &mut current_action, &mut current_patch, ); changes } fn resolve_patch_path(path: &str, session_cwd: &str) -> String { let raw = Path::new(path); if raw.is_absolute() { return raw.display().to_string(); } Path::new(session_cwd).join(raw).display().to_string() } fn fish_fn_path() -> String { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("config") .join("fish") .join("fn.fish") .display() .to_string() } fn is_fish_fn_path(path: &str) -> bool { path.ends_with("/config/fish/fn.fish") || path == fish_fn_path() } fn summarize_fish_fn_change(text: &str) -> Option<String> { let normalized = text.to_ascii_lowercase(); let mut remaps = Vec::new(); if normalized.contains("j is now the fresh codex entrypoint") || normalized.contains("j runs f codex open") || normalized.contains("__flow_codex open --path") { remaps.push("j->codex.open"); } if normalized.contains("k is now the current-folder codex continue entrypoint") || normalized.contains("k uses f codex connect") || normalized.contains("__flow_codex connect --path") { remaps.push("k->codex.connect"); } if normalized.contains("l is now kit") || normalized.contains("kit --continue --no-exit") || normalized.contains("exec \"$kit_bin\" --continue") { remaps.push("l->kit"); } if normalized.contains("l now delegates to j") || text.contains("function L") && text.contains("j $argv") { remaps.push("L->j"); } if remaps.is_empty() { if normalized.contains("fn.fish") { return Some("updated fn.fish".to_string()); } return None; } let keep_fallbacks = normalized.contains("old k moved to cl") || normalized.contains("function cl") || normalized.contains("function cf") || text.contains("function cF"); let mut summary = format!("remap {}", remaps.join(", ")); if keep_fallbacks { summary.push_str("; keep cl/cf/cF fallbacks"); } Some(summary) } fn build_fish_fn_changed_event( row: &CodexRecoverRow, snapshot: &CodexSessionCompletionSnapshot, summary: String, ) -> activity_log::ActivityEvent { let mut event = activity_log::ActivityEvent::changed("fish.fn", summary); event.target_path = Some(fish_fn_path()); event.session_id = Some(row.id.clone()); event.source = Some("codex-session-change".to_string()); event.dedupe_key = snapshot .last_assistant_at_unix .map(|value| format!("codex:changed:{}:{value}:fish.fn", row.id)); event } fn changed_file_label(path: &str) -> String { let path_ref = Path::new(path); if is_fish_fn_path(path) { return "fn.fish".to_string(); } path_ref .file_name() .and_then(|value| value.to_str()) .map(|value| value.to_string()) .unwrap_or_else(|| path.to_string()) } fn summarize_generic_changed_files(changes: &[CodexTurnPatchChange]) -> String { let labels = changes .iter() .map(|change| changed_file_label(&change.path)) .collect::<Vec<_>>(); match labels.len() { 0 => "updated files".to_string(), 1 => format!("updated {}", labels[0]), 2 => format!("updated {}, {}", labels[0], labels[1]), _ => format!( "updated {}, {} + {} more", labels[0], labels[1], labels.len() - 2 ), } } fn build_codex_session_changed_events( row: &CodexRecoverRow, snapshot: &CodexSessionCompletionSnapshot, session_file: &Path, ) -> Result<Vec<activity_log::ActivityEvent>> { let mut events = Vec::new(); let Some(last_assistant_at_unix) = snapshot.last_assistant_at_unix else { return Ok(events); }; let patch_changes = snapshot .last_user_at_unix .map(|last_user_at_unix| { read_codex_turn_patch_changes( session_file, last_user_at_unix, last_assistant_at_unix, &row.cwd, ) }) .transpose()? .unwrap_or_default(); let fish_summary = patch_changes .iter() .find(|change| is_fish_fn_path(&change.path)) .and_then(|change| summarize_fish_fn_change(&change.patch)) .or_else(|| { snapshot .last_assistant_message .as_deref() .and_then(summarize_fish_fn_change) }) .or_else(|| { snapshot .last_user_message .as_deref() .and_then(summarize_fish_fn_change) }); let mut remaining_changes = Vec::new(); for change in patch_changes { if is_fish_fn_path(&change.path) { continue; } remaining_changes.push(change); } if let Some(summary) = fish_summary { events.push(build_fish_fn_changed_event(row, snapshot, summary)); } if !remaining_changes.is_empty() { let mut event = activity_log::ActivityEvent::changed( "files.changed", summarize_generic_changed_files(&remaining_changes), ); event.target_path = Some(row.cwd.clone()); event.launch_path = Some(row.cwd.clone()); event.session_id = Some(row.id.clone()); event.source = Some("codex-session-change".to_string()); event.dedupe_key = Some(format!( "codex:changed:{}:{}:aggregate", row.id, last_assistant_at_unix )); events.push(event); } Ok(events) } fn hydrate_codex_quick_launch( launch: &CodexQuickLaunchEvent, ) -> Result<Option<CodexQuickLaunchHydration>> { let target_path = PathBuf::from(&launch.cwd); if !target_path.exists() { return Ok(None); } let mut candidates = read_recent_codex_threads_local(&target_path, true, 8, None)?; if candidates.is_empty() { candidates = read_recent_codex_threads_local(&target_path, false, 8, None)?; } if candidates.is_empty() { return Ok(None); } let since_unix = launch.recorded_at_unix.saturating_sub(1); let mut best: Option<(u64, String, String)> = None; for candidate in candidates { let Some(session_file) = find_codex_session_file(&candidate.id) else { continue; }; let Some((query, prompt_recorded_at_unix)) = read_codex_first_user_message_since(&session_file, since_unix)? else { continue; }; let replace = best .as_ref() .map(|(best_ts, _, _)| prompt_recorded_at_unix < *best_ts) .unwrap_or(true); if replace { best = Some((prompt_recorded_at_unix, candidate.id, query)); } } let Some((prompt_recorded_at_unix, session_id, query)) = best else { return Ok(None); }; Ok(Some(CodexQuickLaunchHydration { version: 1, launch_id: launch.launch_id.clone(), hydrated_at_unix: SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0), target_path: target_path.display().to_string(), session_id, query, prompt_recorded_at_unix, })) } fn reconcile_pending_codex_quick_launches(limit: usize) -> Result<usize> { let launches = load_recent_codex_quick_launches(limit)?; if launches.is_empty() { return Ok(0); } let hydrated_ids = load_hydrated_codex_quick_launch_ids()?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0); let mut reconciled = 0usize; for launch in launches { if hydrated_ids.contains(&launch.launch_id) { continue; } if now.saturating_sub(launch.recorded_at_unix) < 2 { continue; } let Some(hydration) = hydrate_codex_quick_launch(&launch)? else { continue; }; let event = codex_skill_eval::CodexSkillEvalEvent { version: 1, recorded_at_unix: hydration.prompt_recorded_at_unix, mode: "quick-launch".to_string(), action: if launch.mode == "new" { "new".to_string() } else { "resume".to_string() }, route: "quick-launch-hydrated".to_string(), target_path: hydration.target_path.clone(), launch_path: hydration.target_path.clone(), query: hydration.query.clone(), session_id: Some(hydration.session_id.clone()), runtime_token: None, runtime_skills: Vec::new(), prompt_context_budget_chars: 0, prompt_chars: hydration.query.chars().count(), injected_context_chars: 0, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }; let _ = codex_skill_eval::log_event(&event); let _ = log_codex_quick_launch_hydration(&hydration); let mut activity_event = activity_log::ActivityEvent::done("codex.quick-launch", hydration.query.clone()); activity_event.route = Some(format!("{}-hydrated", launch.mode)); activity_event.target_path = Some(hydration.target_path.clone()); activity_event.launch_path = Some(hydration.target_path.clone()); activity_event.session_id = Some(hydration.session_id.clone()); activity_event.source = Some("codex-quick-launch".to_string()); activity_event.dedupe_key = Some(format!("codex:quick-launch:{}", launch.launch_id)); let _ = activity_log::append_daily_event(activity_event); reconciled += 1; } Ok(reconciled) } pub(crate) fn reconcile_codex_session_completions(limit: usize) -> Result<usize> { if limit == 0 { return Ok(0); } let rows = read_recent_codex_threads_global_local(limit)?; if rows.is_empty() { return Ok(0); } let now = unix_now_secs(); let idle_secs = codex_session_completion_idle_secs(); let _ = prune_codex_session_completion_markers(now); let mut reconciled = 0usize; for row in rows { let Some(session_file) = find_codex_session_file(&row.id) else { continue; }; let Some(snapshot) = read_codex_session_completion_snapshot(&session_file)? else { continue; }; if snapshot.last_role.as_deref() != Some("assistant") { continue; } let Some(last_assistant_at_unix) = snapshot.last_assistant_at_unix else { continue; }; let idle_anchor = snapshot.file_modified_unix.max(last_assistant_at_unix); if now.saturating_sub(idle_anchor) < idle_secs { continue; } if !claim_codex_session_completion_marker(&row.id, last_assistant_at_unix)? { continue; } let _ = activity_log::append_daily_event(build_codex_session_completion_event(&row, &snapshot)); for event in build_codex_session_changed_events(&row, &snapshot, &session_file)? { let _ = activity_log::append_daily_event(event); } reconciled += 1; } Ok(reconciled) } pub(crate) fn run_codex_background_maintenance() -> Result<(usize, usize)> { let hydrated = reconcile_pending_codex_quick_launches(48)?; let completed = reconcile_codex_session_completions(codex_session_completion_scan_limit())?; Ok((hydrated, completed)) } pub(crate) fn maybe_run_codex_telemetry_export(limit: usize) -> Result<usize> { codex_telemetry::maybe_flush(limit) } fn codex_touch_launch(mode: String, cwd: Option<String>, provider: Provider) -> Result<()> { if provider != Provider::Codex { bail!("touch-launch is only supported for Codex sessions; use `f codex touch-launch`"); } let cwd_path = resolve_session_target_path(cwd.as_deref())?; let daemon = if codexd::ensure_running().is_ok() { "running" } else { "unavailable" }; let recorded_at_unix = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0); let mut hasher = std::collections::hash_map::DefaultHasher::new(); mode.hash(&mut hasher); cwd_path.hash(&mut hasher); SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_nanos()) .unwrap_or(0) .hash(&mut hasher); let event = CodexQuickLaunchEvent { version: 1, launch_id: format!("{:016x}", hasher.finish()), recorded_at_unix, mode, cwd: cwd_path.display().to_string(), daemon: daemon.to_string(), }; let _ = log_codex_quick_launch_event(&event); let _ = run_codex_background_maintenance(); Ok(()) } fn codex_daemon_command(action: Option<CodexDaemonAction>, provider: Provider) -> Result<()> { if provider != Provider::Codex { bail!("daemon is only supported for Codex sessions; use `f codex daemon ...`"); } match action.unwrap_or(CodexDaemonAction::Status) { CodexDaemonAction::Start => codexd::start(), CodexDaemonAction::Stop => codexd::stop(), CodexDaemonAction::Restart => { codexd::stop().ok(); std::thread::sleep(Duration::from_millis(300)); codexd::start() } CodexDaemonAction::Status => codexd::status(), CodexDaemonAction::Serve { socket } => codexd::serve(socket.as_deref()), CodexDaemonAction::Ping => codexd::ping(), } } fn codex_memory_command(action: Option<CodexMemoryAction>, provider: Provider) -> Result<()> { if provider != Provider::Codex { bail!("memory is only supported for Codex sessions; use `f codex memory ...`"); } match action.unwrap_or(CodexMemoryAction::Status { json: false }) { CodexMemoryAction::Status { json } => { let stats = codex_memory::stats()?; if json { println!( "{}", serde_json::to_string_pretty(&stats) .context("failed to encode codex memory status JSON")? ); } else { println!("# codex memory"); println!("root: {}", stats.root_dir); println!("db_path: {}", stats.db_path); println!("events_indexed: {}", stats.total_events); println!("facts_indexed: {}", stats.total_facts); println!("skill_eval_events: {}", stats.skill_eval_events); println!("skill_eval_outcomes: {}", stats.skill_eval_outcomes); if let Some(latest) = stats.latest_recorded_at_unix { println!("latest_recorded_at_unix: {}", latest); } } Ok(()) } CodexMemoryAction::Sync { limit, json } => { let _ = reconcile_pending_codex_quick_launches(limit.max(64)); let summary = codex_memory::sync_from_skill_eval_logs(limit)?; if json { println!( "{}", serde_json::to_string_pretty(&summary) .context("failed to encode codex memory sync JSON")? ); } else { println!("# codex memory sync"); println!("total_considered: {}", summary.total_considered); println!("inserted: {}", summary.inserted); println!("skipped: {}", summary.skipped); } Ok(()) } CodexMemoryAction::Query { path, limit, json, query, } => { let query_text = query.join(" ").trim().to_string(); if query_text.is_empty() { bail!("codex memory query requires a search string"); } let target_path = resolve_session_target_path(path.as_deref())?; let result = codex_memory::query_repo_facts(&target_path, &query_text, limit)? .ok_or_else(|| anyhow::anyhow!("no codex memory facts matched {:?}", query_text))?; if json { println!( "{}", serde_json::to_string_pretty(&result) .context("failed to encode codex memory query JSON")? ); } else { println!("{}", result.rendered); } Ok(()) } CodexMemoryAction::Recent { path, limit, json } => { let _ = reconcile_pending_codex_quick_launches(limit.max(64)); let _ = codex_memory::sync_from_skill_eval_logs(limit.max(200)); let target_path = path .as_deref() .map(|value| resolve_session_target_path(Some(value))) .transpose()?; let rows = codex_memory::recent(target_path.as_deref(), limit)?; if json { println!( "{}", serde_json::to_string_pretty(&rows) .context("failed to encode codex memory recent JSON")? ); } else if rows.is_empty() { println!("No codex memory rows recorded."); } else { println!("# codex memory recent"); for row in rows { let subject = row .query .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| truncate_message(value, 96)) .or_else(|| row.route.clone()) .unwrap_or_else(|| "(no query)".to_string()); println!( "- {} | {} | {}", row.event_kind, row.recorded_at_unix, subject ); } } Ok(()) } } } fn codex_telemetry_command( action: Option<CodexTelemetryAction>, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("telemetry is only supported for Codex sessions; use `f codex telemetry ...`"); } match action.unwrap_or(CodexTelemetryAction::Status { json: false }) { CodexTelemetryAction::Status { json } => { let status = codex_telemetry::status()?; if json { println!( "{}", serde_json::to_string_pretty(&status) .context("failed to encode codex telemetry status JSON")? ); } else { println!("# codex telemetry"); println!("enabled: {}", status.enabled); println!("configured_targets: {}", status.configured_targets); println!("service_name: {}", status.service_name); println!("scope_name: {}", status.scope_name); println!("state_path: {}", status.state_path); println!("events_path: {}", status.events_path); println!("outcomes_path: {}", status.outcomes_path); println!("events_offset: {}", status.events_offset); println!("outcomes_offset: {}", status.outcomes_offset); println!("events_exported: {}", status.events_exported); println!("outcomes_exported: {}", status.outcomes_exported); if let Some(last) = status.last_exported_at_unix { println!("last_exported_at_unix: {}", last); } } Ok(()) } CodexTelemetryAction::Flush { limit, json } => { let summary = codex_telemetry::flush(limit)?; if json { println!( "{}", serde_json::to_string_pretty(&summary) .context("failed to encode codex telemetry flush JSON")? ); } else { println!("# codex telemetry flush"); println!("enabled: {}", summary.enabled); println!("configured_targets: {}", summary.configured_targets); println!("events_seen: {}", summary.events_seen); println!("outcomes_seen: {}", summary.outcomes_seen); println!("events_exported: {}", summary.events_exported); println!("outcomes_exported: {}", summary.outcomes_exported); println!("state_path: {}", summary.state_path); if let Some(last) = summary.last_exported_at_unix { println!("last_exported_at_unix: {}", last); } } Ok(()) } } } fn codex_trace_command(action: Option<CodexTraceAction>, provider: Provider) -> Result<()> { if provider != Provider::Codex { bail!("trace is only supported for Codex sessions; use `f codex trace ...`"); } match action.unwrap_or(CodexTraceAction::CurrentSession { flush: true, json: false, }) { CodexTraceAction::Status { json } => { let status = codex_telemetry::trace_status()?; if json { println!( "{}", serde_json::to_string_pretty(&status) .context("failed to encode codex trace status JSON")? ); } else { println!("# codex trace"); println!("enabled: {}", status.enabled); println!("endpoint: {}", status.endpoint); println!("token_source: {}", status.token_source); println!("tools_list_ok: {}", status.tools_list_ok); println!("tools_count: {}", status.tools_count); println!("read_probe_ok: {}", status.read_probe_ok); if let Some(error) = status.read_probe_error.as_deref() { println!("read_probe_error: {}", error); } } Ok(()) } CodexTraceAction::CurrentSession { flush, json } => { let current = codex_telemetry::inspect_current_session_trace(flush)?; if json { println!( "{}", serde_json::to_string_pretty(¤t) .context("failed to encode current codex trace JSON")? ); } else { println!("# codex current-session trace"); println!("trace_id: {}", current.trace_id); if let Some(span_id) = current.span_id.as_deref() { println!("span_id: {}", span_id); } if let Some(parent_span_id) = current.parent_span_id.as_deref() { println!("parent_span_id: {}", parent_span_id); } if let Some(workflow_kind) = current.workflow_kind.as_deref() { println!("workflow_kind: {}", workflow_kind); } if let Some(service_name) = current.service_name.as_deref() { println!("service_name: {}", service_name); } println!("flushed: {}", current.flushed); println!("endpoint: {}", current.endpoint); println!("token_source: {}", current.token_source); if let Some(error) = current.read_error.as_deref() { println!("read_error: {}", error); } if let Some(result) = current.result.as_ref() { println!( "{}", serde_json::to_string_pretty(result) .context("failed to encode current codex trace result")? ); } } Ok(()) } CodexTraceAction::Inspect { trace_id, flush, json, } => { let inspected = codex_telemetry::inspect_trace(&trace_id, flush)?; if json { println!( "{}", serde_json::to_string_pretty(&inspected) .context("failed to encode codex trace inspect JSON")? ); } else { println!("# codex trace inspect"); println!("trace_id: {}", inspected.trace_id); println!("flushed: {}", inspected.flushed); println!("endpoint: {}", inspected.endpoint); println!("token_source: {}", inspected.token_source); if let Some(error) = inspected.read_error.as_deref() { println!("read_error: {}", error); } if let Some(result) = inspected.result.as_ref() { println!( "{}", serde_json::to_string_pretty(result) .context("failed to encode codex trace inspect result")? ); } } Ok(()) } } } fn codex_skill_eval_command( action: Option<CodexSkillEvalAction>, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("skill-eval is only supported for Codex sessions; use `f codex skill-eval ...`"); } match action.unwrap_or(CodexSkillEvalAction::Show { path: None, json: false, }) { CodexSkillEvalAction::Run { path, limit, json } => { let _ = reconcile_pending_codex_quick_launches(limit.max(48)); let target_path = resolve_session_target_path(path.as_deref())?; let scorecard = codex_skill_eval::rebuild_scorecard(&target_path, limit)?; if json { println!( "{}", serde_json::to_string_pretty(&scorecard) .context("failed to encode codex skill-eval JSON")? ); } else { println!("{}", codex_skill_eval::format_scorecard(&scorecard)); } Ok(()) } CodexSkillEvalAction::Show { path, json } => { let _ = reconcile_pending_codex_quick_launches(64); let target_path = resolve_session_target_path(path.as_deref())?; let scorecard = codex_skill_eval::load_scorecard(&target_path)? .unwrap_or(codex_skill_eval::rebuild_scorecard(&target_path, 200)?); if json { println!( "{}", serde_json::to_string_pretty(&scorecard) .context("failed to encode codex skill-eval JSON")? ); } else { println!("{}", codex_skill_eval::format_scorecard(&scorecard)); } Ok(()) } CodexSkillEvalAction::Events { path, limit, json } => { let _ = reconcile_pending_codex_quick_launches(limit.max(48)); let target_path = path .as_deref() .map(|value| resolve_session_target_path(Some(value))) .transpose()?; let events = codex_skill_eval::load_events(target_path.as_deref(), limit)?; if json { println!( "{}", serde_json::to_string_pretty(&events) .context("failed to encode codex skill-eval events JSON")? ); } else if events.is_empty() { println!("No codex skill-eval events recorded."); } else { println!("# codex skill-eval events"); for event in events { println!( "- {} | {} | {} | skills {}", event.mode, event.route, event.target_path, if event.runtime_skills.is_empty() { "(none)".to_string() } else { event.runtime_skills.join(", ") } ); } } Ok(()) } CodexSkillEvalAction::Cron { limit, max_targets, within_hours, json, } => { let reconciled = reconcile_pending_codex_quick_launches(limit.max(64))?; let memory_sync = codex_memory::sync_from_skill_eval_logs(limit.max(200))?; let targets = codex_skill_eval::recent_targets(limit, max_targets, within_hours)?; let mut capsule_sync_count = 0usize; let mut scorecards = Vec::new(); for target in targets { if codex_memory::sync_repo_capsule_for_path(&target).is_ok() { capsule_sync_count += 1; } scorecards.push(codex_skill_eval::rebuild_scorecard(&target, limit)?); } if json { println!( "{}", serde_json::to_string_pretty(&json!({ "reconciledQuickLaunches": reconciled, "memorySync": memory_sync, "capsulesSynced": capsule_sync_count, "scorecards": scorecards, })) .context("failed to encode codex skill-eval cron JSON")? ); } else if scorecards.is_empty() { println!( "No recent Codex skill-eval targets found. Reconciled {} fast launch(es), indexed {} memory event(s), synced {} repo capsule(s).", reconciled, memory_sync.inserted, capsule_sync_count ); } else { println!("# codex skill-eval cron"); println!("reconciled fast launches: {}", reconciled); println!("memory inserted: {}", memory_sync.inserted); println!("repo capsules synced: {}", capsule_sync_count); for scorecard in scorecards { let top = scorecard .skills .first() .map(|skill| format!("{} ({:.2})", skill.name, skill.score)) .unwrap_or_else(|| "none".to_string()); println!( "- {} | samples {} | top {}", scorecard.target_path, scorecard.samples, top ); } } Ok(()) } } } fn codex_skill_source_command( action: Option<CodexSkillSourceAction>, provider: Provider, ) -> Result<()> { if provider != Provider::Codex { bail!("skill-source is only supported for Codex sessions; use `f codex skill-source ...`"); } match action.unwrap_or(CodexSkillSourceAction::List { path: None, json: false, }) { CodexSkillSourceAction::List { path, json } => { let target_path = resolve_session_target_path(path.as_deref())?; let codex_cfg = load_codex_config_for_path(&target_path); let skills = codex_runtime::discover_external_skills(&target_path, &codex_cfg)?; if json { println!( "{}", serde_json::to_string_pretty(&skills) .context("failed to encode codex skill-source JSON")? ); } else { println!("{}", codex_runtime::format_external_skills(&skills)); } Ok(()) } CodexSkillSourceAction::Sync { path, skills, force, } => { let target_path = resolve_session_target_path(path.as_deref())?; let codex_cfg = load_codex_config_for_path(&target_path); let installed = codex_runtime::sync_external_skills(&target_path, &codex_cfg, &skills, force)?; println!( "Synced {} external Codex skill(s) into ~/.codex/skills.", installed ); Ok(()) } } } fn codex_runtime_command(action: Option<CodexRuntimeAction>, provider: Provider) -> Result<()> { if provider != Provider::Codex { bail!("runtime helpers are only supported for Codex sessions; use `f codex runtime ...`"); } match action.unwrap_or(CodexRuntimeAction::Show) { CodexRuntimeAction::Show => { let states = codex_runtime::load_runtime_states()?; println!("{}", codex_runtime::format_runtime_states(&states)); } CodexRuntimeAction::Clear => { let removed = codex_runtime::clear_runtime_states()?; println!( "Cleared {} Flow-managed Codex runtime state file(s).", removed ); } CodexRuntimeAction::WritePlan { title, stem, dir, source_session, } => { let path = codex_runtime::write_plan_from_stdin( title.as_deref(), stem.as_deref(), dir.as_deref(), source_session.as_deref(), )?; println!("{}", path.display()); } } Ok(()) } fn normalize_codex_resolve_args(query: Vec<String>, json_output: bool) -> (Vec<String>, bool) { if json_output { return (query, true); } let mut normalized = query; let mut resolved_json = false; while matches!(normalized.last().map(String::as_str), Some("--json")) { normalized.pop(); resolved_json = true; } (normalized, resolved_json) } fn build_codex_open_plan( path: Option<String>, query: Vec<String>, exact_cwd: bool, ) -> Result<CodexOpenPlan> { let target_path = resolve_session_target_path(path.as_deref())?; let query_text = normalize_recover_query(&query); let codex_cfg = load_codex_config_for_path(&target_path); let auto_resolve_references = codex_cfg.auto_resolve_references.unwrap_or(true); let max_resolved_references = effective_max_resolved_references(&codex_cfg); let runtime_skills_enabled = codex_cfg.runtime_skills.unwrap_or(false) && codex_runtime_transport_enabled(&target_path); let default_prompt_budget = effective_prompt_context_budget_chars(&codex_cfg, false); let Some(query_text) = query_text else { let prompt = None; return Ok(finalize_codex_open_plan(CodexOpenPlan { action: "new".to_string(), route: "new-empty".to_string(), reason: "no query provided".to_string(), target_path: target_path.display().to_string(), launch_path: target_path.display().to_string(), query: None, session_id: None, prompt, references: Vec::new(), runtime_state_path: None, runtime_skills: Vec::new(), prompt_context_budget_chars: default_prompt_budget, max_resolved_references, prompt_chars: 0, injected_context_chars: 0, trace: None, })); }; let normalized_query = query_text.to_ascii_lowercase(); if let Some(request) = extract_codex_session_reference_request(&query_text, &normalized_query) { let mut references = Vec::new(); for session_hint in &request.session_hints { let reference = resolve_builtin_codex_session_reference(session_hint, request.count)?; if !references .iter() .any(|existing: &CodexResolvedReference| existing.matched == reference.matched) { references.push(reference); } } if auto_resolve_references { let extra_references = resolve_codex_references( &target_path, &request.user_request, &codex_cfg.reference_resolvers, )?; for reference in extra_references { if !references .iter() .any(|existing| existing.matched == reference.matched) { references.push(reference); } } } let runtime = codex_runtime::prepare_runtime_activation( &target_path, &request.user_request, runtime_skills_enabled, &codex_cfg, )?; let prompt_budget = effective_prompt_context_budget_chars(&codex_cfg, true); let prompt = build_codex_prompt_with_runtime( &request.user_request, &references, runtime.as_ref(), max_resolved_references, prompt_budget, ); let route = if request.session_hints.len() > 1 { "multi-session-reference-new" } else { "session-reference-new" }; let reason = if request.session_hints.len() > 1 { format!( "start a new session with {} resolved Codex session contexts", request.session_hints.len() ) } else { "start a new session with resolved Codex session context".to_string() }; return Ok(finalize_codex_open_plan(CodexOpenPlan { action: "new".to_string(), route: route.to_string(), reason, target_path: target_path.display().to_string(), launch_path: target_path.display().to_string(), query: Some(query_text), session_id: None, prompt, references, runtime_state_path: runtime .as_ref() .map(|value| value.state_path.display().to_string()), runtime_skills: runtime_skill_names(runtime.as_ref()), prompt_context_budget_chars: prompt_budget, max_resolved_references, prompt_chars: 0, injected_context_chars: 0, trace: None, })); } if let Some(plan) = build_codex_commit_workflow_plan( &target_path, &query_text, &normalized_query, runtime_skills_enabled, auto_resolve_references, max_resolved_references, default_prompt_budget, &codex_cfg, )? { return Ok(plan); } if let Some(plan) = build_codex_sync_workflow_plan( &target_path, &query_text, &normalized_query, max_resolved_references, default_prompt_budget, )? { return Ok(plan); } if looks_like_recovery_prompt(&normalized_query) { return build_codex_recovery_plan( &target_path, exact_cwd, &query_text, runtime_skills_enabled, default_prompt_budget, max_resolved_references, ); } if let Some((session, reason)) = resolve_codex_session_lookup(&target_path, exact_cwd, &query_text, &normalized_query)? { return Ok(finalize_codex_open_plan(CodexOpenPlan { action: "resume".to_string(), route: "resume-existing".to_string(), reason, target_path: target_path.display().to_string(), launch_path: session.cwd.clone(), query: Some(query_text), session_id: Some(session.id), prompt: None, references: Vec::new(), runtime_state_path: None, runtime_skills: Vec::new(), prompt_context_budget_chars: default_prompt_budget, max_resolved_references, prompt_chars: 0, injected_context_chars: 0, trace: None, })); } if looks_like_session_lookup_query(&normalized_query) { bail!( "{}", build_codex_open_no_match_message(&target_path, exact_cwd, &query_text)? ); } let references = if auto_resolve_references { resolve_codex_references(&target_path, &query_text, &codex_cfg.reference_resolvers)? } else { Vec::new() }; let runtime = codex_runtime::prepare_runtime_activation( &target_path, &query_text, runtime_skills_enabled, &codex_cfg, )?; let prompt_budget = effective_prompt_context_budget_chars(&codex_cfg, has_session_reference(&references)); let prompt = build_codex_prompt_with_runtime( &query_text, &references, runtime.as_ref(), max_resolved_references, prompt_budget, ); Ok(finalize_codex_open_plan(CodexOpenPlan { action: "new".to_string(), route: if references.is_empty() { "new-plain".to_string() } else { "new-with-context".to_string() }, reason: if references.is_empty() { "start a new session from the current query".to_string() } else { "start a new session with compact resolved context".to_string() }, target_path: target_path.display().to_string(), launch_path: target_path.display().to_string(), query: Some(query_text), session_id: None, prompt, references, runtime_state_path: runtime .as_ref() .map(|value| value.state_path.display().to_string()), runtime_skills: runtime_skill_names(runtime.as_ref()), prompt_context_budget_chars: prompt_budget, max_resolved_references, prompt_chars: 0, injected_context_chars: 0, trace: None, })) } fn build_codex_commit_workflow_plan( target_path: &Path, query_text: &str, normalized_query: &str, runtime_skills_enabled: bool, auto_resolve_references: bool, max_resolved_references: usize, default_prompt_budget: usize, codex_cfg: &config::CodexConfig, ) -> Result<Option<CodexOpenPlan>> { if !looks_like_commit_workflow_query(normalized_query) { return Ok(None); } let Some(repo_root) = detect_git_root(target_path) else { return Ok(None); }; let mut references = vec![resolve_builtin_commit_workflow_reference(&repo_root)?]; if auto_resolve_references { for reference in resolve_codex_references(&repo_root, query_text, &codex_cfg.reference_resolvers)? { if !references .iter() .any(|existing| existing.matched == reference.matched) { references.push(reference); } } } let runtime = codex_runtime::prepare_runtime_activation( &repo_root, query_text, runtime_skills_enabled, codex_cfg, )?; let prompt_budget = effective_prompt_context_budget_chars(codex_cfg, has_session_reference(&references)) .max(2200); let prompt = build_codex_prompt_with_runtime( query_text, &references, runtime.as_ref(), max_resolved_references, prompt_budget, ); Ok(Some(finalize_codex_open_plan(CodexOpenPlan { action: "new".to_string(), route: "commit-workflow-new".to_string(), reason: "start a new session with enforced deep-review commit workflow".to_string(), target_path: repo_root.display().to_string(), launch_path: repo_root.display().to_string(), query: Some(query_text.to_string()), session_id: None, prompt, references, runtime_state_path: runtime .as_ref() .map(|value| value.state_path.display().to_string()), runtime_skills: runtime_skill_names(runtime.as_ref()), prompt_context_budget_chars: prompt_budget.max(default_prompt_budget), max_resolved_references, prompt_chars: 0, injected_context_chars: 0, trace: None, }))) } fn build_codex_sync_workflow_plan( target_path: &Path, query_text: &str, normalized_query: &str, max_resolved_references: usize, default_prompt_budget: usize, ) -> Result<Option<CodexOpenPlan>> { if !looks_like_prom_sync_workflow_query(normalized_query) { return Ok(None); } let Some(repo_root) = detect_git_root(target_path) else { return Ok(None); }; if !is_prom_workspace_path(&repo_root) { return Ok(None); } let references = vec![resolve_builtin_sync_workflow_reference(&repo_root)?]; let prompt_budget = default_prompt_budget.max(1600); let prompt = build_codex_prompt(query_text, &references, max_resolved_references, prompt_budget); Ok(Some(finalize_codex_open_plan(CodexOpenPlan { action: "new".to_string(), route: "sync-workflow-new".to_string(), reason: "start a new session with enforced guarded sync workflow".to_string(), target_path: repo_root.display().to_string(), launch_path: repo_root.display().to_string(), query: Some(query_text.to_string()), session_id: None, prompt, references, runtime_state_path: None, runtime_skills: Vec::new(), prompt_context_budget_chars: prompt_budget, max_resolved_references, prompt_chars: 0, injected_context_chars: 0, trace: None, }))) } fn execute_codex_open_plan(plan: &CodexOpenPlan) -> Result<()> { let launch_path = PathBuf::from(&plan.launch_path); match plan.action.as_str() { "resume" => { let session_id = plan .session_id .as_deref() .ok_or_else(|| anyhow::anyhow!("missing session id for resume plan"))?; println!( "Opening Codex session {} in {}...", truncate_recover_id(session_id), launch_path.display() ); if launch_session_for_target( session_id, Provider::Codex, plan.prompt.as_deref(), Some(&launch_path), plan.runtime_state_path.as_deref(), plan.trace.as_ref(), )? { Ok(()) } else { bail!("failed to resume codex session {}", session_id); } } "new" | "recover-new" => { maybe_open_cursor_for_pr_feedback_check(plan); new_session_for_target( Provider::Codex, plan.prompt.as_deref(), Some(&launch_path), plan.runtime_state_path.as_deref(), plan.trace.as_ref(), ) } other => bail!("unsupported codex open action: {}", other), } } fn maybe_open_cursor_for_pr_feedback_check(plan: &CodexOpenPlan) { let Some(query) = plan .query .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; if !looks_like_pr_feedback_query(query) { return; } if env_flag_is_false("FLOW_OPEN_CURSOR_ON_PR_CHECK") { return; } let Some(handoff) = plan .references .iter() .find(|reference| reference.name == "pr-feedback") .and_then(|reference| parse_pr_feedback_cursor_handoff(&reference.output)) else { return; }; let _ = open_cursor_review_handoff(&handoff); } fn env_flag_is_false(name: &str) -> bool { let Ok(value) = env::var(name) else { return false; }; matches!( value.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off" ) } fn parse_pr_feedback_cursor_handoff(value: &str) -> Option<PrFeedbackCursorHandoff> { let mut workspace_path = None; let mut review_plan_path = None; let mut review_rules_path = None; let mut kit_system_path = None; for line in value.lines().map(str::trim) { if let Some(path) = line.strip_prefix("Workspace:") { workspace_path = Some(PathBuf::from(path.trim())); } else if let Some(path) = line.strip_prefix("Review plan:") { review_plan_path = Some(PathBuf::from(path.trim())); } else if let Some(path) = line.strip_prefix("Review rules:") { review_rules_path = Some(PathBuf::from(path.trim())); } else if let Some(path) = line.strip_prefix("Kit system prompt:") { kit_system_path = Some(PathBuf::from(path.trim())); } } Some(PrFeedbackCursorHandoff { workspace_path: workspace_path?, review_plan_path: review_plan_path?, review_rules_path, kit_system_path: kit_system_path?, }) } fn command_on_path(command: &str) -> bool { let Some(path_os) = env::var_os("PATH") else { return false; }; env::split_paths(&path_os).any(|dir| dir.join(command).is_file()) } fn open_cursor_review_handoff(handoff: &PrFeedbackCursorHandoff) -> Result<()> { let mut command = if cfg!(target_os = "macos") || !command_on_path("cursor") { let mut command = Command::new("open"); command.arg("-g").arg("-a").arg("Cursor"); command } else { Command::new("cursor") }; command .arg(&handoff.workspace_path) .arg(&handoff.review_plan_path) .args(handoff.review_rules_path.iter()) .arg(&handoff.kit_system_path) .stdout(Stdio::null()) .stderr(Stdio::null()); let _status = command.status()?; Ok(()) } fn print_codex_open_plan(plan: &CodexOpenPlan) { println!("# codex resolve"); println!("action: {}", plan.action); println!("route: {}", plan.route); println!("reason: {}", plan.reason); println!("target: {}", plan.target_path); println!("launch: {}", plan.launch_path); println!( "budget: {} chars, up to {} reference(s)", plan.prompt_context_budget_chars, plan.max_resolved_references ); if let Some(session_id) = plan.session_id.as_deref() { println!("session: {}", truncate_recover_id(session_id)); } if !plan.references.is_empty() { println!("references:"); for reference in &plan.references { println!( "- {} [{}] {}", reference.name, reference.source, reference.matched ); } } if !plan.runtime_skills.is_empty() { println!("runtime:"); for skill in &plan.runtime_skills { println!("- {}", skill); } if let Some(path) = plan.runtime_state_path.as_deref() { println!("runtime_state: {}", path); } } if let Some(prompt) = plan.prompt.as_deref() { println!("prompt_chars: {}", plan.prompt_chars); println!("injected_context_chars: {}", plan.injected_context_chars); println!("prompt:"); println!("{}", compact_codex_context_block(prompt, 12, 900)); } } fn record_codex_open_plan(plan: &CodexOpenPlan, mode: &str) { let Some(query) = plan .query .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let event = codex_skill_eval::CodexSkillEvalEvent { version: 1, recorded_at_unix: SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0), mode: mode.to_string(), action: plan.action.clone(), route: plan.route.clone(), target_path: plan.target_path.clone(), launch_path: plan.launch_path.clone(), query: query.to_string(), session_id: plan.session_id.clone(), runtime_token: plan.runtime_state_path.as_deref().and_then(|path| { Path::new(path) .file_stem() .and_then(|value| value.to_str()) .map(|value| value.to_string()) }), runtime_skills: plan.runtime_skills.clone(), prompt_context_budget_chars: plan.prompt_context_budget_chars, prompt_chars: plan.prompt_chars, injected_context_chars: plan.injected_context_chars, reference_count: plan.references.len(), trace_id: plan.trace.as_ref().map(|trace| trace.trace_id.clone()), span_id: plan.trace.as_ref().map(|trace| trace.span_id.clone()), parent_span_id: plan .trace .as_ref() .and_then(|trace| trace.parent_span_id.clone()), workflow_kind: plan.trace.as_ref().map(|trace| trace.workflow_kind.clone()), service_name: plan.trace.as_ref().map(|trace| trace.service_name.clone()), }; let _ = codex_skill_eval::log_event(&event); let mut activity_event = activity_log::ActivityEvent::done(format!("codex.{mode}"), query.to_string()); activity_event.route = Some(plan.route.clone()); activity_event.target_path = Some(plan.target_path.clone()); activity_event.launch_path = Some(plan.launch_path.clone()); activity_event.session_id = plan.session_id.clone(); activity_event.runtime_token = plan.runtime_state_path.as_deref().and_then(|path| { Path::new(path) .file_stem() .and_then(|value| value.to_str()) .map(|value| value.to_string()) }); activity_event.source = Some("codex-open-plan".to_string()); let _ = activity_log::append_daily_event(activity_event); } fn load_codex_config_for_path(target_path: &Path) -> config::CodexConfig { let mut resolved = config::CodexConfig::default(); let global_path = config::default_config_path(); if global_path.exists() && let Ok(cfg) = config::load(&global_path) && let Some(codex_cfg) = cfg.codex { resolved.merge(codex_cfg); } if let Some(local_path) = project_snapshot::find_flow_toml_upwards(target_path) && local_path != global_path && let Ok(cfg) = config::load(&local_path) && let Some(codex_cfg) = cfg.codex { resolved.merge(codex_cfg); } resolved } fn default_codex_connect_path() -> PathBuf { let seed = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let cfg = load_codex_config_for_path(&seed); if let Some(path) = cfg .home_session_path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { return config::expand_path(path); } dirs::home_dir() .unwrap_or_else(|| PathBuf::from("~")) .join("repos") .join("openai") .join("codex") } fn resolve_codex_connect_target_path(path: Option<String>) -> Result<PathBuf> { match path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { Some(value) => resolve_session_target_path(Some(value)), None => Ok(default_codex_connect_path()), } } fn looks_like_recovery_prompt(normalized_query: &str) -> bool { normalized_query.contains("see this convo") || normalized_query.contains("what was i doing") || normalized_query.contains("recover recent context") || normalized_query.contains("recover context") || (normalized_query.contains("continue the") && (normalized_query.contains(" work") || normalized_query.contains(" session") || normalized_query.contains(" convo") || normalized_query.contains(" conversation"))) } fn looks_like_commit_workflow_query(normalized_query: &str) -> bool { let collapsed = normalized_query .split_whitespace() .collect::<Vec<_>>() .join(" "); matches!( collapsed.as_str(), "commit" | "commit this" | "commit these" | "commit it" | "commit now" | "commit please" | "commit and push" | "commit & push" | "commit/push" | "review and commit" | "review commit" | "review, commit, and push" ) } fn looks_like_prom_sync_workflow_query(normalized_query: &str) -> bool { let collapsed = normalized_query .split_whitespace() .collect::<Vec<_>>() .join(" "); matches!( collapsed.as_str(), "sync branch" | "sync this branch" | "sync with origin/main" | "sync with origin main" ) } fn looks_like_session_lookup_query(normalized_query: &str) -> bool { extract_codex_session_hint(normalized_query).is_some() || looks_like_directional_session_query(normalized_query) || parse_ordinal_index(normalized_query).is_some() || looks_like_latest_query(normalized_query) || (contains_lookup_subject(normalized_query) && starts_with_session_control_phrase(normalized_query)) } fn looks_like_directional_session_query(query: &str) -> bool { let has_direction = find_word_boundary(query, "after").is_some() || find_word_boundary(query, "before").is_some(); has_direction && (contains_lookup_subject(query) || starts_with_session_control_phrase(query)) } fn contains_lookup_subject(query: &str) -> bool { [ "session", "sessions", "conversation", "conversations", "convo", "convos", ] .iter() .any(|value| query.split_whitespace().any(|word| word == *value)) } fn starts_with_session_control_phrase(query: &str) -> bool { [ "open ", "resume ", "continue ", "connect ", "find ", "recover ", "show ", "see ", "copy ", "summarize ", "what was i doing", ] .iter() .any(|prefix| query.starts_with(prefix)) } fn resolve_codex_session_lookup( target_path: &Path, exact_cwd: bool, query_text: &str, normalized_query: &str, ) -> Result<Option<(CodexRecoverRow, String)>> { if let Some(session_hint) = extract_codex_session_hint(normalized_query) { let rows = read_codex_threads_by_session_hint(&session_hint, 1)?; if let Some(row) = rows.into_iter().next() { return Ok(Some(( row, format!("explicit session id/prefix `{}`", session_hint), ))); } } if let Some((row, reason)) = resolve_directional_session_lookup(target_path, exact_cwd, normalized_query)? { return Ok(Some((row, reason))); } if let Some(index) = parse_ordinal_index(normalized_query) { let rows = read_recent_codex_threads(target_path, exact_cwd, index + 1, None)?; if let Some(row) = rows.into_iter().nth(index) { return Ok(Some((row, format!("ordinal session match #{}", index + 1)))); } } if looks_like_latest_query(normalized_query) { let rows = read_recent_codex_threads(target_path, exact_cwd, 1, None)?; if let Some(row) = rows.into_iter().next() { return Ok(Some((row, "latest recent session".to_string()))); } } if looks_like_session_lookup_query(normalized_query) { let rows = search_codex_threads_for_find(Some(target_path), exact_cwd, query_text, 1)?; if let Some(row) = rows.into_iter().next() { return Ok(Some((row, "matched session search query".to_string()))); } } Ok(None) } fn resolve_directional_session_lookup( target_path: &Path, exact_cwd: bool, normalized_query: &str, ) -> Result<Option<(CodexRecoverRow, String)>> { if !looks_like_directional_session_query(normalized_query) { return Ok(None); } let Some((direction, anchor_text)) = split_directional_query(normalized_query) else { return Ok(None); }; let recent_rows = read_recent_codex_threads(target_path, exact_cwd, 50, None)?; if recent_rows.is_empty() { return Ok(None); } let anchor = if let Some(index) = parse_ordinal_index(&anchor_text) { recent_rows.get(index).cloned() } else if anchor_text.is_empty() || looks_like_latest_query(&anchor_text) { recent_rows.first().cloned() } else if let Some(session_hint) = extract_codex_session_hint(&anchor_text) { read_codex_threads_by_session_hint(&session_hint, 1)? .into_iter() .next() } else { search_codex_threads_for_find(Some(target_path), exact_cwd, &anchor_text, 1)? .into_iter() .next() }; let Some(anchor) = anchor else { return Ok(None); }; let Some(anchor_index) = recent_rows.iter().position(|row| row.id == anchor.id) else { return Ok(None); }; let selected = if direction == "after" { recent_rows.get(anchor_index + 1).cloned() } else { anchor_index .checked_sub(1) .and_then(|index| recent_rows.get(index).cloned()) }; Ok(selected.map(|row| { ( row, format!("{} session relative to `{}`", direction, anchor_text.trim()), ) })) } fn split_directional_query(query: &str) -> Option<(String, String)> { for direction in ["after", "before"] { if let Some(index) = find_word_boundary(query, direction) { let anchor = query[index + direction.len()..].trim().to_string(); return Some((direction.to_string(), anchor)); } } None } fn find_word_boundary(text: &str, needle: &str) -> Option<usize> { let haystack = text.as_bytes(); let needle_bytes = needle.as_bytes(); let last = haystack.len().checked_sub(needle_bytes.len())?; for start in 0..=last { if &haystack[start..start + needle_bytes.len()] != needle_bytes { continue; } let before_ok = start == 0 || !haystack[start - 1].is_ascii_alphanumeric(); let after_index = start + needle_bytes.len(); let after_ok = after_index >= haystack.len() || !haystack[after_index].is_ascii_alphanumeric(); if before_ok && after_ok { return Some(start); } } None } fn parse_ordinal_index(query: &str) -> Option<usize> { let filtered = strip_codex_control_words(query); if filtered.len() == 1 { if let Ok(value) = filtered[0].parse::<usize>() { if value > 0 { return Some(value - 1); } } let ordinal = match filtered[0].as_str() { "1st" | "first" | "one" => Some(0), "2nd" | "second" | "two" => Some(1), "3rd" | "third" | "three" => Some(2), "4th" | "fourth" | "four" => Some(3), "5th" | "fifth" | "five" => Some(4), "6th" | "sixth" | "six" => Some(5), "7th" | "seventh" | "seven" => Some(6), "8th" | "eighth" | "eight" => Some(7), "9th" | "ninth" | "nine" => Some(8), "10th" | "tenth" | "ten" => Some(9), _ => None, }; if ordinal.is_some() { return ordinal; } } None } fn looks_like_latest_query(query: &str) -> bool { let filtered = strip_codex_control_words(query); filtered.is_empty() && (query.contains("most recent") || query.contains("latest") || query.contains("newest") || query.contains("last")) } fn strip_codex_control_words(query: &str) -> Vec<String> { query .split(|ch: char| !ch.is_ascii_alphanumeric()) .filter(|part| !part.is_empty()) .map(|part| part.to_ascii_lowercase()) .filter(|part| { !matches!( part.as_str(), "connect" | "open" | "resume" | "continue" | "session" | "sessions" | "conversation" | "conversations" | "convo" | "convos" | "after" | "before" | "most" | "recent" | "latest" | "newest" | "last" | "active" | "the" | "a" | "an" | "to" | "from" | "for" | "please" ) }) .collect() } fn build_codex_recovery_plan( target_path: &Path, exact_cwd: bool, query_text: &str, runtime_skills_enabled: bool, prompt_context_budget_chars: usize, max_resolved_references: usize, ) -> Result<CodexOpenPlan> { let rows = read_recent_codex_threads(target_path, exact_cwd, 3, Some(query_text))?; let output = build_recover_output(target_path, exact_cwd, Some(query_text.to_string()), rows); let launch_path = output .candidates .first() .map(|value| value.cwd.clone()) .unwrap_or_else(|| target_path.display().to_string()); if output.candidates.is_empty() { bail!("{}", output.summary); } let recovery_prompt = build_recovery_prompt(query_text, &output, prompt_context_budget_chars); let codex_cfg = load_codex_config_for_path(target_path); let runtime = codex_runtime::prepare_runtime_activation( target_path, query_text, runtime_skills_enabled, &codex_cfg, )?; let prompt = runtime .as_ref() .map(|value| value.inject_into_prompt(&recovery_prompt)) .or(Some(recovery_prompt)); Ok(finalize_codex_open_plan(CodexOpenPlan { action: "recover-new".to_string(), route: "recover-new".to_string(), reason: "explicit recovery prompt".to_string(), target_path: target_path.display().to_string(), launch_path, query: Some(query_text.to_string()), session_id: None, prompt, references: Vec::new(), runtime_state_path: runtime .as_ref() .map(|value| value.state_path.display().to_string()), runtime_skills: runtime_skill_names(runtime.as_ref()), prompt_context_budget_chars, max_resolved_references, prompt_chars: 0, injected_context_chars: 0, trace: None, })) } fn build_recovery_prompt( query_text: &str, output: &CodexRecoverOutput, max_chars: usize, ) -> String { let mut lines = vec!["Recovered recent Codex context:".to_string()]; for candidate in output.candidates.iter().take(2) { let preview = candidate .first_user_message .as_deref() .or(candidate.title.as_deref()) .map(truncate_recover_text) .unwrap_or_else(|| "(no stored prompt text)".to_string()); let model = codex_model_label( candidate.model.as_deref(), candidate.reasoning_effort.as_deref(), ); let line = if let Some(model) = model { format!( "- {} | {} | {} | {} | {}", truncate_recover_id(&candidate.id), candidate.updated_at, model, candidate.cwd, preview ) } else { format!( "- {} | {} | {} | {}", truncate_recover_id(&candidate.id), candidate.updated_at, candidate.cwd, preview ) }; lines.push(line); } lines.push(String::new()); lines.push("User request:".to_string()); lines.push(query_text.trim().to_string()); compact_codex_context_block(&lines.join("\n"), 10, max_chars) } fn build_codex_open_no_match_message( target_path: &Path, exact_cwd: bool, query_text: &str, ) -> Result<String> { let output = build_recover_output( target_path, exact_cwd, Some(query_text.to_string()), read_recent_codex_threads(target_path, exact_cwd, 5, None)?, ); Ok(format!( "No Codex session matched {:?}.\n{}", query_text, output.summary )) } fn resolve_codex_references( target_path: &Path, query_text: &str, resolvers: &[config::CodexReferenceResolverConfig], ) -> Result<Vec<CodexResolvedReference>> { let candidates = extract_reference_candidates(query_text); let mut matches = Vec::new(); for resolver in resolvers { if let Some(reference) = resolve_external_reference(target_path, query_text, &candidates, resolver)? { matches.push(reference); } if matches.len() >= 2 { return Ok(matches); } } let remaining = 2usize.saturating_sub(matches.len()); if remaining > 0 { for reference in resolve_builtin_repo_references(target_path, query_text, &candidates, remaining)? { if !matches .iter() .any(|value| value.matched == reference.matched) { matches.push(reference); } if matches.len() >= 2 { return Ok(matches); } } } if let Some(reference) = resolve_builtin_linear_reference(query_text, &candidates) && !matches .iter() .any(|value| value.matched == reference.matched) { matches.push(reference); } if let Some(reference) = resolve_builtin_url_reference(target_path, query_text, &candidates, &matches) && !matches .iter() .any(|value| value.matched == reference.matched) { matches.push(reference); } Ok(matches) } fn resolve_builtin_repo_references( target_path: &Path, query_text: &str, candidates: &[String], limit: usize, ) -> Result<Vec<CodexResolvedReference>> { let references = repo_capsule::resolve_reference_candidates(target_path, query_text, candidates, limit)?; Ok(references .into_iter() .map(|reference| { let memory_context = codex_memory::query_repo_facts(Path::new(&reference.repo_root), query_text, 4) .ok() .flatten() .map(|result| compact_codex_context_block(&result.rendered, 8, 700)); let output = if let Some(memory) = memory_context { format!("{}\n{}", reference.output, memory) } else { reference.output }; CodexResolvedReference { name: "repo".to_string(), source: "repo".to_string(), matched: reference.matched, command: None, output, } }) .collect()) } fn resolve_external_reference( target_path: &Path, query_text: &str, candidates: &[String], resolver: &config::CodexReferenceResolverConfig, ) -> Result<Option<CodexResolvedReference>> { for candidate in candidates { if !resolver .matches .iter() .any(|pattern| wildcard_match(pattern, candidate)) { continue; } let command_text = render_reference_resolver_command( &resolver.command, candidate, query_text, target_path, ); let args = shell_words::split(&command_text) .with_context(|| format!("invalid resolver command: {}", command_text))?; let Some((program, rest)) = args.split_first() else { bail!("empty resolver command for {}", resolver.name); }; let output = Command::new(program) .args(rest) .current_dir(target_path) .output() .with_context(|| format!("failed to run resolver {}", resolver.name))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); bail!( "resolver {} failed for {}: {}", resolver.name, candidate, if stderr.is_empty() { format!("exit status {}", output.status) } else { stderr } ); } let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); if stdout.is_empty() { bail!( "resolver {} returned empty output for {}", resolver.name, candidate ); } return Ok(Some(CodexResolvedReference { name: resolver .inject_as .clone() .unwrap_or_else(|| resolver.name.clone()), source: "resolver".to_string(), matched: candidate.clone(), command: Some(command_text), output: compact_codex_context_block(&stdout, 12, 1200), })); } Ok(None) } fn render_reference_resolver_command( template: &str, matched: &str, query_text: &str, target_path: &Path, ) -> String { template .replace("{{ref}}", &shell_words::quote(matched)) .replace("{{query}}", &shell_words::quote(query_text)) .replace( "{{cwd}}", &shell_words::quote(&target_path.display().to_string()), ) } fn resolve_builtin_linear_reference( query_text: &str, candidates: &[String], ) -> Option<CodexResolvedReference> { for candidate in candidates { if let Some(reference) = parse_linear_url_reference(candidate) { return Some(CodexResolvedReference { name: "linear".to_string(), source: "builtin".to_string(), matched: candidate.clone(), command: None, output: render_linear_url_reference(&reference), }); } } let _ = query_text; None } fn resolve_builtin_url_reference( target_path: &Path, query_text: &str, candidates: &[String], existing: &[CodexResolvedReference], ) -> Option<CodexResolvedReference> { for candidate in candidates { if !looks_like_http_url(candidate) { continue; } if existing.iter().any(|value| value.matched == *candidate) { continue; } if looks_like_github_pr_url(candidate) && looks_like_pr_feedback_query(query_text) { let Ok(output) = crate::commit::resolve_pr_feedback_reference(target_path, candidate) else { continue; }; return Some(CodexResolvedReference { name: "pr-feedback".to_string(), source: "builtin".to_string(), matched: candidate.clone(), command: Some(format!("f pr feedback {}", shell_words::quote(candidate))), output: compact_codex_context_block(&output, 16, 2400), }); } let Ok(output) = url_inspect::inspect_compact(candidate, target_path) else { continue; }; return Some(CodexResolvedReference { name: "url".to_string(), source: "builtin".to_string(), matched: candidate.clone(), command: None, output: compact_codex_context_block(&output, 10, 900), }); } None } fn resolve_builtin_commit_workflow_reference(repo_root: &Path) -> Result<CodexResolvedReference> { let status = capture_git_stdout(repo_root, &["status", "--short"]).unwrap_or_default(); let staged_diff = capture_git_stdout(repo_root, &["diff", "--cached", "--stat", "--compact-summary"]) .unwrap_or_default(); let working_diff = if staged_diff.trim().is_empty() { capture_git_stdout(repo_root, &["diff", "--stat", "--compact-summary"]).unwrap_or_default() } else { String::new() }; let review_instructions = crate::commit::get_review_instructions(repo_root).unwrap_or_default(); let agents_instructions = read_repo_agents_instructions(repo_root).unwrap_or_default(); let kit_gate = detect_commit_workflow_kit_gate(repo_root); let output = render_commit_workflow_reference( repo_root, &status, &staged_diff, &working_diff, &review_instructions, &agents_instructions, kit_gate.as_deref(), ); Ok(CodexResolvedReference { name: "commit-workflow".to_string(), source: "builtin".to_string(), matched: "commit".to_string(), command: Some("f commit --slow --context".to_string()), output: compact_codex_context_block(&output, 20, 2200), }) } fn resolve_builtin_sync_workflow_reference(repo_root: &Path) -> Result<CodexResolvedReference> { let agents_instructions = read_repo_agents_instructions(repo_root).unwrap_or_default(); let command = detect_sync_workflow_command(repo_root); let output = render_sync_workflow_reference( repo_root, &agents_instructions, command.as_deref().unwrap_or("forge sync"), ); Ok(CodexResolvedReference { name: "sync-workflow".to_string(), source: "builtin".to_string(), matched: "sync branch".to_string(), command, output: compact_codex_context_block(&output, 18, 1600), }) } fn capture_git_stdout(repo_root: &Path, args: &[&str]) -> Option<String> { let output = Command::new("git") .args(args) .current_dir(repo_root) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8(output.stdout).ok()?; let trimmed = stdout.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } fn render_commit_workflow_reference( repo_root: &Path, status: &str, staged_diff: &str, working_diff: &str, review_instructions: &str, agents_instructions: &str, kit_gate: Option<&str>, ) -> String { let mut lines = vec![ "Commit workflow contract:".to_string(), format!("Workspace: {}", repo_root.display()), "Interpret plain `commit` as deep-review-then-commit, not the fast lane.".to_string(), "If you use Flow CLI for the final commit, prefer `f commit --slow --context` over plain `f commit`.".to_string(), "Default focus: correctness, regression risk, performance, robustness, and clear intent.".to_string(), "Preferred execution shape: keep the main thread lean and, if available, use a detached Codex review lane or subagent to inspect the diff in parallel and only surface blocking issues back to the main thread.".to_string(), "Treat repo AGENTS.md and repo review instructions as binding commit constraints.".to_string(), ]; if let Some(kit_gate) = kit_gate { lines.push(format!("Deterministic gate: {}", kit_gate)); } lines.extend([ "Required operating order:".to_string(), "1. Inspect the actual local diff and adjacent call sites before deciding anything.".to_string(), "2. Run deterministic local gates before the final commit when they are available.".to_string(), "3. Explain the intent behind the change and the main risks.".to_string(), "4. Name the smallest validation that proves the change is safe.".to_string(), "5. Draft a commit title/body that explains why the change was made, not just what changed.".to_string(), "6. Only commit/push once the review is clean and the change is scoped.".to_string(), ]); if !status.trim().is_empty() { lines.push(String::new()); lines.push("Git status:".to_string()); lines.push(render_compact_bullet_block(status, 10)); } if !staged_diff.trim().is_empty() { lines.push(String::new()); lines.push("Staged diff stat:".to_string()); lines.push(render_compact_bullet_block(staged_diff, 12)); } else if !working_diff.trim().is_empty() { lines.push(String::new()); lines.push("Working tree diff stat (nothing staged yet):".to_string()); lines.push(render_compact_bullet_block(working_diff, 12)); } else { lines.push(String::new()); lines.push("Diff state: working tree is clean right now.".to_string()); } if !review_instructions.trim().is_empty() { lines.push(String::new()); lines.push("Repo commit review instructions:".to_string()); lines.push(compact_codex_context_block(review_instructions.trim(), 5, 500)); } lines.push(String::new()); lines.push("Final deliverable contract:".to_string()); lines.push("- provide one short review summary covering correctness, perf, robustness, and regression risk".to_string()); lines.push("- provide exact validation commands or manual checks".to_string()); lines.push("- provide the final commit title and body with explicit intent, not only file-level changes".to_string()); let _ = agents_instructions; lines.join("\n") } fn render_sync_workflow_reference(repo_root: &Path, agents_instructions: &str, command: &str) -> String { let mut lines = vec![ "Sync workflow contract:".to_string(), format!("Workspace: {}", repo_root.display()), "Interpret plain `sync branch` as the guarded repo sync workflow, not raw `git pull`, generic rebase steps, or improvised JJ commands.".to_string(), format!("Preferred command: {}", command), "Required operating order:".to_string(), "1. Read ./AGENTS.md if it exists and treat repo workflow instructions as binding.".to_string(), "2. Use the guarded sync path for this repo instead of ad hoc Git/JJ commands.".to_string(), "3. Preserve branch-aware behavior and explain what changed.".to_string(), "4. If sync fails, report the blocker and next safe step instead of improvising.".to_string(), "Final deliverable contract:".to_string(), "- state whether sync succeeded".to_string(), "- summarize the main changes pulled in".to_string(), "- name any remaining blocker or follow-up".to_string(), ]; if !agents_instructions.trim().is_empty() { lines.push(String::new()); lines.push("Repo workflow instructions:".to_string()); lines.push(compact_codex_context_block(agents_instructions.trim(), 5, 400)); } lines.join("\n") } fn render_compact_bullet_block(value: &str, max_lines: usize) -> String { let mut lines = Vec::new(); for line in value.lines().map(str::trim).filter(|line| !line.is_empty()) { lines.push(format!("- {}", truncate_message(line, 140))); if lines.len() >= max_lines { break; } } if lines.is_empty() { "- none".to_string() } else { lines.join("\n") } } fn read_repo_agents_instructions(repo_root: &Path) -> Option<String> { let path = repo_root.join("AGENTS.md"); let content = fs::read_to_string(path).ok()?; let trimmed = content.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } fn detect_commit_workflow_kit_gate(repo_root: &Path) -> Option<String> { if !command_on_path("kit") { return None; } Some(format!( "cd {} && kit lint --setup never && kit review --dir . --json", shell_words::quote(&repo_root.display().to_string()) )) } fn is_prom_workspace_path(path: &Path) -> bool { let display = path.display().to_string(); display.contains("/code/prom") || display.contains("/.jj/workspaces/prom/") } fn detect_sync_workflow_command(repo_root: &Path) -> Option<String> { if !is_prom_workspace_path(repo_root) { return None; } Some("forge sync".to_string()) } fn extract_reference_candidates(query_text: &str) -> Vec<String> { let mut seen = BTreeSet::new(); let mut candidates = Vec::new(); let trimmed = trim_reference_token(query_text); if !trimmed.is_empty() && seen.insert(trimmed.to_string()) { candidates.push(trimmed.to_string()); } for token in query_text.split_whitespace() { let trimmed = trim_reference_token(token); if trimmed.is_empty() { continue; } if seen.insert(trimmed.to_string()) { candidates.push(trimmed.to_string()); } } candidates } fn trim_reference_token(value: &str) -> &str { value.trim_matches(|ch: char| { matches!( ch, '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | '.' | ';' ) }) } fn looks_like_http_url(value: &str) -> bool { let trimmed = trim_reference_token(value); trimmed.starts_with("https://") || trimmed.starts_with("http://") } fn looks_like_github_pr_url(value: &str) -> bool { let trimmed = trim_reference_token(value).trim_end_matches('/'); let Some(rest) = trimmed.strip_prefix("https://github.com/") else { return false; }; let mut parts = rest.split('/'); let owner = parts.next().unwrap_or_default().trim(); let repo = parts.next().unwrap_or_default().trim(); let kind = parts.next().unwrap_or_default().trim(); let number = parts.next().unwrap_or_default().trim(); !owner.is_empty() && !repo.is_empty() && kind == "pull" && number.parse::<u64>().is_ok() } fn looks_like_pr_feedback_query(query_text: &str) -> bool { let lowered = query_text.to_ascii_lowercase(); lowered.contains("check ") || lowered.starts_with("check") || lowered.contains("feedback") || lowered.contains("comments") || lowered.contains("review") || lowered.contains("lint") } fn wildcard_match(pattern: &str, candidate: &str) -> bool { let pattern = pattern.to_ascii_lowercase(); let candidate = candidate.to_ascii_lowercase(); if !pattern.contains('*') { return pattern == candidate; } let mut remainder = candidate.as_str(); let mut anchored = true; for segment in pattern.split('*') { if segment.is_empty() { anchored = false; continue; } if anchored { let Some(stripped) = remainder.strip_prefix(segment) else { return false; }; remainder = stripped; } else if let Some(index) = remainder.find(segment) { remainder = &remainder[index + segment.len()..]; } else { return false; } anchored = false; } pattern.ends_with('*') || remainder.is_empty() } fn parse_linear_url_reference(value: &str) -> Option<LinearUrlReference> { let trimmed = trim_reference_token(value); let relative = trimmed.strip_prefix("https://linear.app/")?; let relative = relative .split(['?', '#']) .next() .unwrap_or(relative) .trim_matches('/'); let segments: Vec<_> = relative .split('/') .filter(|segment| !segment.is_empty()) .collect(); if segments.len() < 3 { return None; } let workspace_slug = segments[0].to_string(); match segments[1] { "issue" => Some(LinearUrlReference { url: trimmed.to_string(), workspace_slug, resource_kind: LinearUrlKind::Issue, resource_value: segments[2].to_string(), view: None, title_hint: segments[2].to_string(), }), "project" => { let project_slug = segments[2].to_string(); let title_hint = humanize_linear_slug(&project_slug); Some(LinearUrlReference { url: trimmed.to_string(), workspace_slug, resource_kind: LinearUrlKind::Project, resource_value: project_slug, view: segments.get(3).map(|value| (*value).to_string()), title_hint, }) } _ => None, } } fn humanize_linear_slug(value: &str) -> String { let mut parts: Vec<_> = value.split('-').filter(|part| !part.is_empty()).collect(); if parts .last() .is_some_and(|part| part.len() >= 8 && part.chars().all(|ch| ch.is_ascii_hexdigit())) { parts.pop(); } if parts.is_empty() { value.to_string() } else { parts.join(" ") } } fn render_linear_url_reference(reference: &LinearUrlReference) -> String { let mut lines = vec![format!("- Linear URL: {}", reference.url)]; lines.push(format!("- Linear workspace: {}", reference.workspace_slug)); match reference.resource_kind { LinearUrlKind::Issue => { lines.push(format!("- Linear issue: {}", reference.resource_value)); } LinearUrlKind::Project => { lines.push(format!( "- Linear project slug: {}", reference.resource_value )); lines.push(format!("- Linear project hint: {}", reference.title_hint)); if let Some(view) = reference.view.as_deref() { lines.push(format!("- Linear project view: {}", view)); } } } compact_codex_context_block(&lines.join("\n"), 8, 700) } fn build_codex_prompt( query_text: &str, references: &[CodexResolvedReference], max_resolved_references: usize, max_chars: usize, ) -> Option<String> { let trimmed_query = query_text.trim(); if references.is_empty() { if trimmed_query.is_empty() { return None; } return Some(trimmed_query.to_string()); } let mut lines = vec!["Resolved context:".to_string()]; let selected: Vec<_> = references.iter().take(max_resolved_references).collect(); for (index, reference) in selected.iter().enumerate() { let current_chars = lines.iter().map(|line| line.chars().count()).sum::<usize>(); let query_reserve = if trimmed_query.is_empty() { 0 } else { trimmed_query.chars().count() + "User request:".chars().count() + 8 }; let remaining = max_chars.saturating_sub(current_chars + query_reserve); if remaining < 80 { break; } let refs_left = selected.len().saturating_sub(index).max(1); let per_ref_budget = (remaining / refs_left).clamp(120, max_chars.max(120)); let header = format!("[{}]", reference.name); if !reference.output.trim_start().starts_with(&header) { lines.push(header); } lines.push(compact_codex_context_block( &reference.output, 8, per_ref_budget, )); } if !trimmed_query.is_empty() { lines.push(String::new()); lines.push("User request:".to_string()); lines.push(trimmed_query.to_string()); } let (max_lines, max_chars) = if has_session_reference(references) { (24, max_chars) } else { (14, max_chars) }; Some(compact_codex_context_block( &lines.join("\n"), max_lines, max_chars, )) } fn build_codex_prompt_with_runtime( query_text: &str, references: &[CodexResolvedReference], runtime: Option<&codex_runtime::CodexRuntimeActivation>, max_resolved_references: usize, max_chars: usize, ) -> Option<String> { let prompt = build_codex_prompt(query_text, references, max_resolved_references, max_chars)?; Some( runtime .map(|value| value.inject_into_prompt(&prompt)) .unwrap_or(prompt), ) } fn has_session_reference(references: &[CodexResolvedReference]) -> bool { references .iter() .any(|reference| reference.source == "session") } fn effective_max_resolved_references(codex_cfg: &config::CodexConfig) -> usize { codex_cfg.max_resolved_references.unwrap_or(2).clamp(1, 6) } fn effective_prompt_context_budget_chars( codex_cfg: &config::CodexConfig, has_session_reference: bool, ) -> usize { codex_cfg .prompt_context_budget_chars .unwrap_or(if has_session_reference { 2200 } else { 1200 }) .clamp(300, 12_000) } fn new_workflow_trace_id() -> String { Uuid::new_v4().simple().to_string() } fn new_workflow_span_id() -> String { Uuid::new_v4().simple().to_string()[..16].to_string() } fn workflow_kind_from_route(route: &str) -> String { route .trim() .trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-') .replace('-', "_") } fn trace_context_from_reference( reference: &CodexResolvedReference, workflow_kind: String, ) -> Option<CodexResolveWorkflowTrace> { let fields = parse_reference_fields(&reference.output); let trace_id = fields.get("trace id")?.trim(); if trace_id.is_empty() { return None; } Some(CodexResolveWorkflowTrace { trace_id: trace_id.to_string(), span_id: new_workflow_span_id(), parent_span_id: None, workflow_kind, service_name: FLOW_CODEX_TRACE_SERVICE_NAME.to_string(), }) } fn derive_codex_open_plan_trace(plan: &CodexOpenPlan) -> Option<CodexResolveWorkflowTrace> { if let Some(reference) = plan .references .iter() .find(|reference| reference.name == "pr-feedback") .and_then(|reference| trace_context_from_reference(reference, "pr_feedback".to_string())) { return Some(reference); } Some(CodexResolveWorkflowTrace { trace_id: new_workflow_trace_id(), span_id: new_workflow_span_id(), parent_span_id: None, workflow_kind: workflow_kind_from_route(&plan.route), service_name: FLOW_CODEX_TRACE_SERVICE_NAME.to_string(), }) } fn finalize_codex_open_plan(mut plan: CodexOpenPlan) -> CodexOpenPlan { if plan.trace.is_none() { plan.trace = derive_codex_open_plan_trace(&plan); } plan.prompt_chars = plan .prompt .as_deref() .map(|value| value.chars().count()) .unwrap_or(0); let query_chars = plan .query .as_deref() .map(str::trim) .map(|value| value.chars().count()) .unwrap_or(0); plan.injected_context_chars = plan.prompt_chars.saturating_sub(query_chars); plan } fn runtime_skill_names(runtime: Option<&codex_runtime::CodexRuntimeActivation>) -> Vec<String> { runtime .map(|value| { value .skills .iter() .map(|skill| { skill .original_name .clone() .unwrap_or_else(|| skill.name.clone()) }) .collect() }) .unwrap_or_default() } fn compact_codex_context_block(value: &str, max_lines: usize, max_chars: usize) -> String { let mut lines = Vec::new(); let mut chars = 0usize; for line in value .lines() .map(str::trim_end) .filter(|line| !line.is_empty()) { let line_chars = line.chars().count(); if lines.len() >= max_lines || chars + line_chars > max_chars { break; } lines.push(line.to_string()); chars += line_chars; } let mut out = lines.join("\n"); if out.chars().count() > max_chars { out = out .chars() .take(max_chars.saturating_sub(1)) .collect::<String>() + "…"; } out } /// Copy session history to clipboard. fn copy_session(session: Option<String>, provider: Provider) -> Result<()> { // Auto-import any new sessions silently auto_import_sessions()?; if session.is_none() && provider != Provider::All { return copy_last_session(provider, None); } // Handle provider shortcuts: "claude" or "codex" -> copy last session for that provider if let Some(ref query) = session { let q = query.to_lowercase(); if q == "claude" || q == "c" { return copy_last_session(Provider::Claude, None); } if q == "codex" || q == "x" { return copy_last_session(Provider::Codex, None); } if q == "cursor" || q == "u" { return copy_last_session(Provider::Cursor, None); } } let index = load_index()?; let sessions = read_sessions_for_project(provider)?; if sessions.is_empty() && session.is_none() { let provider_name = match provider { Provider::Claude => "Claude", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; println!("No {} sessions found for this project.", provider_name); return Ok(()); } if session.is_none() && !io::stdin().is_terminal() { bail!("no session specified (interactive selection requires a TTY)"); } // Find the session ID and provider let (session_id, session_provider) = if let Some(ref query) = session { resolve_session_selection(query, &sessions, &index, provider)? } else { // Show fzf selection let mut entries: Vec<FzfSessionEntry> = Vec::new(); for session in &sessions { if session.timestamp.is_none() && session.last_message_at.is_none() && session.last_message.is_none() && session.first_message.is_none() && session.error_summary.is_none() { continue; } let relative_time = session .last_message_at .as_deref() .or(session.timestamp.as_deref()) .map(format_relative_time) .unwrap_or_else(|| "".to_string()); let saved_name = index .sessions .iter() .find(|(_, s)| s.id == session.session_id) .map(|(name, _)| name.as_str()) .filter(|name| !is_auto_generated_name(name)); let summary = session .last_message .as_deref() .or(session.first_message.as_deref()) .or(session.error_summary.as_deref()) .unwrap_or(""); let summary_clean = clean_summary(summary); let id_short = &session.session_id[..8.min(session.session_id.len())]; // Add provider indicator when showing all let provider_tag = if provider == Provider::All { match session.provider { Provider::Claude => "claude | ", Provider::Codex => "codex | ", Provider::Cursor => "cursor | ", Provider::All => "", } } else { "" }; let display = if let Some(name) = saved_name { format!( "{}{} | {} | {}", provider_tag, name, relative_time, truncate_str(&summary_clean, 40) ) } else { format!( "{}{} | {} | {}", provider_tag, relative_time, truncate_str(&summary_clean, 60), id_short ) }; entries.push(FzfSessionEntry { display, session_id: session.session_id.clone(), provider: session.provider, }); } if entries.is_empty() { println!("No sessions available."); return Ok(()); } if which::which("fzf").is_err() { bail!("fzf not found – install it for fuzzy selection"); } let Some(selected) = run_session_fzf(&entries)? else { return Ok(()); }; (selected.session_id.clone(), selected.provider) }; // Read and format the session history let history = read_session_history(&session_id, session_provider)?; // Copy to clipboard copy_to_clipboard(&history)?; let line_count = history.lines().count(); println!("Copied session history ({} lines) to clipboard", line_count); Ok(()) } fn copy_session_history_to_clipboard(session_id: &str, provider: Provider) -> Result<usize> { let history = read_session_history(session_id, provider)?; copy_to_clipboard(&history)?; Ok(history.lines().count()) } /// Copy the most recent session for a provider directly (no fzf selection). /// If search query is provided, searches ALL sessions globally for matching content. fn copy_last_session(provider: Provider, search: Option<String>) -> Result<()> { // Auto-import any new sessions silently auto_import_sessions()?; // If search query provided, search all sessions globally if let Some(query) = search { return copy_session_by_search(provider, &query); } let sessions = read_sessions_for_project(provider)?; if sessions.is_empty() { let provider_name = match provider { Provider::Claude => "Claude", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; println!("No {} sessions found for this project.", provider_name); return Ok(()); } // sessions are already sorted by most recent first let session = &sessions[0]; // Read and format the session history let history = read_session_history(&session.session_id, session.provider)?; // Copy to clipboard copy_to_clipboard(&history)?; let line_count = history.lines().count(); let id_short = &session.session_id[..8.min(session.session_id.len())]; println!( "Copied session {} ({} lines) to clipboard", id_short, line_count ); Ok(()) } /// Search all sessions globally for content matching the query. fn copy_session_by_search(provider: Provider, query: &str) -> Result<()> { let query_lower = query.to_lowercase(); // Search Codex sessions if provider == Provider::Codex || provider == Provider::All { let sessions_dir = get_codex_sessions_dir(); if sessions_dir.exists() { for file_path in collect_codex_session_files(&sessions_dir) { // Read raw content and check for query if let Ok(content) = fs::read_to_string(&file_path) { if content.to_lowercase().contains(&query_lower) { // Found a match - get session ID and read formatted history let filename = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let session_id = filename.split('_').next().unwrap_or(filename); let history = read_session_history(session_id, Provider::Codex)?; copy_to_clipboard(&history)?; let line_count = history.lines().count(); let id_short = &session_id[..8.min(session_id.len())]; // Try to get project path from session if let Some((_, cwd)) = parse_codex_session_file(&file_path, filename) { if let Some(project_path) = cwd { println!( "Copied session {} from {} ({} lines) to clipboard", id_short, project_path.display(), line_count ); return Ok(()); } } println!( "Copied session {} ({} lines) to clipboard", id_short, line_count ); return Ok(()); } } } } } // Search Cursor sessions if provider == Provider::Cursor || provider == Provider::All { let projects_dir = get_cursor_projects_dir(); if projects_dir.exists() { if let Ok(entries) = fs::read_dir(&projects_dir) { for entry in entries.flatten() { let project_dir = entry.path(); if !project_dir.is_dir() { continue; } for file_path in collect_cursor_project_session_files(&project_dir) { if let Ok(content) = fs::read_to_string(&file_path) { if content.to_lowercase().contains(&query_lower) { let session_id = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let history = read_session_history(session_id, Provider::Cursor)?; copy_to_clipboard(&history)?; let line_count = history.lines().count(); let id_short = &session_id[..8.min(session_id.len())]; let project_name = project_dir .file_name() .and_then(|s| s.to_str()) .and_then(decode_cursor_project_path) .and_then(|path| { path.file_name() .and_then(|name| name.to_str()) .map(str::to_string) }) .unwrap_or_else(|| { project_dir .file_name() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string() }); println!( "Copied session {} from {} ({} lines) to clipboard", id_short, project_name, line_count ); return Ok(()); } } } } } } } // Search Claude sessions if provider == Provider::Claude || provider == Provider::All { let projects_dir = get_claude_projects_dir(); if projects_dir.exists() { if let Ok(entries) = fs::read_dir(&projects_dir) { for entry in entries.flatten() { let project_dir = entry.path(); if !project_dir.is_dir() { continue; } if let Ok(files) = fs::read_dir(&project_dir) { for file in files.flatten() { let file_path = file.path(); if file_path.extension().map(|e| e == "jsonl").unwrap_or(false) { if let Ok(content) = fs::read_to_string(&file_path) { if content.to_lowercase().contains(&query_lower) { let session_id = file_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or(""); let history = read_session_history(session_id, Provider::Claude)?; copy_to_clipboard(&history)?; let line_count = history.lines().count(); let id_short = &session_id[..8.min(session_id.len())]; let project_name = project_dir .file_name() .and_then(|s| s.to_str()) .unwrap_or("unknown"); println!( "Copied session {} from {} ({} lines) to clipboard", id_short, project_name, line_count ); return Ok(()); } } } } } } } } } println!("No session found containing: {}", query); Ok(()) } fn append_history_message( history: &mut String, last_entry: &mut Option<(String, String)>, role: &str, content: &str, ) { let trimmed = content.trim(); if trimmed.is_empty() { return; } let role_label = match role { "user" => "Human", "assistant" => "Assistant", _ => return, }; let content_key = trimmed.to_string(); if let Some((last_role, last_content)) = last_entry.as_ref() { if last_role == role_label && last_content == &content_key { return; } } history.push_str(role_label); history.push_str(": "); history.push_str(trimmed); history.push_str("\n\n"); *last_entry = Some((role_label.to_string(), content_key)); } /// Read full session history from JSONL file and format as conversation. fn read_session_history(session_id: &str, provider: Provider) -> Result<String> { let session_file = if provider == Provider::Codex { // Codex stores sessions in ~/.codex/sessions/ with different structure find_codex_session_file(session_id) .ok_or_else(|| anyhow::anyhow!("Codex session file not found: {}", session_id))? } else if provider == Provider::Cursor { find_cursor_session_file(session_id) .ok_or_else(|| anyhow::anyhow!("Cursor session file not found: {}", session_id))? } else { let cwd = std::env::current_dir()?; let cwd_str = cwd.to_string_lossy().to_string(); let project_folder = path_to_project_name(&cwd_str); let projects_dir = get_claude_projects_dir(); projects_dir .join(&project_folder) .join(format!("{}.jsonl", session_id)) }; if !session_file.exists() { bail!("Session file not found: {}", session_file.display()); } let mut history = String::new(); let mut last_entry: Option<(String, String)> = None; for_each_nonempty_jsonl_line(&session_file, |line| { let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else { return; }; // Cursor format (top-level role + nested message.content) if let Some(role) = entry .get("role") .and_then(|r| r.as_str()) .map(normalize_cursor_role) { let content_text = extract_content_text( entry .get("message") .and_then(|message| message.get("content")), ); if let Some(cleaned) = normalize_session_message(role, &content_text) { append_history_message(&mut history, &mut last_entry, role, &cleaned); } return; } // Try Claude format first (entry.message.role + entry.message.content) if let Some(msg) = entry.get("message") { let role = msg .get("role") .and_then(|r| r.as_str()) .unwrap_or("unknown"); let content_text = extract_content_text(msg.get("content")); if let Some(cleaned) = normalize_session_message(role, &content_text) { append_history_message(&mut history, &mut last_entry, role, &cleaned); } return; } // Try Codex format (type: response_item, payload.type: message) if entry.get("type").and_then(|t| t.as_str()) == Some("response_item") { if let Some(payload) = entry.get("payload") { if payload.get("type").and_then(|t| t.as_str()) == Some("message") { let role = payload .get("role") .and_then(|r| r.as_str()) .unwrap_or("unknown"); let content_text = payload .get("content") .and_then(extract_codex_content_text) .unwrap_or_default(); if let Some(cleaned) = normalize_session_message(role, &content_text) { append_history_message(&mut history, &mut last_entry, role, &cleaned); } } } } })?; Ok(history) } /// Extract text content from various content formats. fn extract_content_text(content: Option<&serde_json::Value>) -> String { let Some(content) = content else { return String::new(); }; match content { serde_json::Value::String(s) => s.clone(), serde_json::Value::Array(arr) => { arr.iter() .filter_map(|v| { // Handle text blocks (Claude uses "text", Codex uses "text" in input_text type) v.get("text") .and_then(|t| t.as_str()) .map(|s| s.to_string()) }) .collect::<Vec<_>>() .join("\n") } _ => String::new(), } } /// Strip <system-reminder>...</system-reminder> blocks from text. fn strip_system_reminders(text: &str) -> String { let mut result = text.to_string(); while let Some(start) = result.find("<system-reminder>") { if let Some(end) = result[start..].find("</system-reminder>") { let end_pos = start + end + "</system-reminder>".len(); result = format!("{}{}", &result[..start], &result[end_pos..]); } else { // Unclosed tag - remove from start to end result = result[..start].to_string(); break; } } result.trim().to_string() } /// Check if content is boilerplate that should be skipped. fn is_session_boilerplate(text: &str) -> bool { let trimmed = text.trim(); // === Codex boilerplate === // Skip agents.md instructions if trimmed.starts_with("# AGENTS.md instructions") || trimmed.starts_with("# agents.md instructions") { return true; } // Skip environment context if trimmed.starts_with("<environment_context>") { return true; } // Skip instructions blocks if trimmed.starts_with("<INSTRUCTIONS>") { return true; } // Skip permissions instructions (Codex system context) if trimmed.contains("<permissions instructions>") { return true; } // Skip developer role messages with system instructions if trimmed.starts_with("developer:") { return true; } // Skip skill usage announcements if trimmed.starts_with("Using ") && trimmed.contains("skill") { return true; } // === Claude boilerplate === // Skip system reminders if trimmed.starts_with("<system-reminder>") { return true; } // Skip messages that are only system reminders if trimmed.contains("<system-reminder>") && !trimmed.contains("Human:") && !trimmed.contains("Assistant:") { // Check if the non-reminder content is minimal let without_reminders = trimmed .split("<system-reminder>") .next() .unwrap_or("") .trim(); if without_reminders.is_empty() { return true; } } false } /// Copy last prompt and response from a session to clipboard. fn copy_context( session: Option<String>, provider: Provider, count: usize, path: Option<String>, ) -> Result<()> { // Auto-import any new sessions silently auto_import_sessions()?; // Treat "-" as None (trigger fuzzy search) let mut session = session.filter(|s| s != "-"); let mut path = path; // Allow `f ai context .` to mean "use current path" instead of a session ID. if path.is_none() { if let Some(ref candidate) = session { let candidate_path = PathBuf::from(candidate); if candidate == "." || candidate == ".." || candidate_path.exists() { path = Some(candidate.clone()); session = None; } } } // Determine project path let project_path = if let Some(ref p) = path { PathBuf::from(p) } else { std::env::current_dir()? }; let index = load_index()?; let sessions = read_sessions_for_path(provider, &project_path)?; if sessions.is_empty() && session.is_none() { let provider_name = match provider { Provider::Claude => "Claude", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; println!("No {} sessions found for this project.", provider_name); return Ok(()); } // Find the session ID and provider let (session_id, session_provider) = if let Some(ref query) = session { resolve_session_selection(query, &sessions, &index, provider)? } else { // Show fzf selection let mut entries: Vec<FzfSessionEntry> = Vec::new(); for session in &sessions { if session.timestamp.is_none() && session.last_message_at.is_none() && session.last_message.is_none() && session.first_message.is_none() && session.error_summary.is_none() { continue; } let relative_time = session .last_message_at .as_deref() .or(session.timestamp.as_deref()) .map(format_relative_time) .unwrap_or_else(|| "".to_string()); let saved_name = index .sessions .iter() .find(|(_, s)| s.id == session.session_id) .map(|(name, _)| name.as_str()) .filter(|name| !is_auto_generated_name(name)); let summary = session .last_message .as_deref() .or(session.first_message.as_deref()) .or(session.error_summary.as_deref()) .unwrap_or(""); let summary_clean = clean_summary(summary); let id_short = &session.session_id[..8.min(session.session_id.len())]; let provider_tag = if provider == Provider::All { match session.provider { Provider::Claude => "claude | ", Provider::Codex => "codex | ", Provider::Cursor => "cursor | ", Provider::All => "", } } else { "" }; let display = if let Some(name) = saved_name { format!( "{}{} | {} | {}", provider_tag, name, relative_time, truncate_str(&summary_clean, 40) ) } else { format!( "{}{} | {} | {}", provider_tag, relative_time, truncate_str(&summary_clean, 60), id_short ) }; entries.push(FzfSessionEntry { display, session_id: session.session_id.clone(), provider: session.provider, }); } if entries.is_empty() { println!("No sessions available."); return Ok(()); } if which::which("fzf").is_err() { bail!("fzf not found – install it for fuzzy selection"); } let Some(selected) = run_session_fzf(&entries)? else { return Ok(()); }; (selected.session_id.clone(), selected.provider) }; // Read the last N exchanges let context = read_last_context(&session_id, session_provider, count, &project_path)?; // Copy to clipboard copy_to_clipboard(&context)?; let exchange_word = if count == 1 { "exchange" } else { "exchanges" }; let line_count = context.lines().count(); println!( "Copied last {} {} ({} lines) to clipboard", count, exchange_word, line_count ); Ok(()) } /// Print a cleaned session excerpt to stdout. fn show_session( session: Option<String>, provider: Provider, count: usize, path: Option<String>, full: bool, ) -> Result<()> { auto_import_sessions()?; let mut session = session.filter(|value| value != "-"); let mut path = path; if path.is_none() { if let Some(ref candidate) = session { let candidate_path = PathBuf::from(candidate); if candidate == "." || candidate == ".." || candidate_path.exists() { path = Some(candidate.clone()); session = None; } } } let project_path = if let Some(ref p) = path { PathBuf::from(p) } else { std::env::current_dir()? }; let index = load_index()?; let sessions = read_sessions_for_path(provider, &project_path)?; let (session_id, session_provider) = if let Some(ref query) = session { resolve_session_selection(query, &sessions, &index, provider)? } else { let latest = sessions.first().ok_or_else(|| { let provider_name = match provider { Provider::Claude => "Claude", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", }; anyhow::anyhow!( "No {provider_name} sessions found for {}", project_path.display() ) })?; (latest.session_id.clone(), latest.provider) }; let output = if full { read_session_history(&session_id, session_provider)? } else { read_last_context(&session_id, session_provider, count.max(1), &project_path)? }; print!("{}", output); Ok(()) } /// Read last N user prompts and assistant responses from a session. fn read_last_context( session_id: &str, provider: Provider, count: usize, project_path: &PathBuf, ) -> Result<String> { if provider == Provider::Codex { let session_file = find_codex_session_file(session_id).ok_or_else(|| { anyhow::anyhow!("Session file not found for Codex session {}", session_id) })?; return read_codex_last_context(&session_file, count); } if provider == Provider::Cursor { let session_file = find_cursor_session_file(session_id).ok_or_else(|| { anyhow::anyhow!("Session file not found for Cursor session {}", session_id) })?; return read_cursor_last_context(&session_file, count); } let path_str = project_path.to_string_lossy().to_string(); let project_folder = path_to_project_name(&path_str); let projects_dir = match provider { Provider::Claude | Provider::All => get_claude_projects_dir(), Provider::Codex => get_codex_projects_dir(), Provider::Cursor => get_cursor_projects_dir(), }; let session_file = projects_dir .join(&project_folder) .join(format!("{}.jsonl", session_id)); if !session_file.exists() { bail!("Session file not found: {}", session_file.display()); } // Collect only the trailing `count` exchanges to bound memory usage for large sessions. let keep = count.max(1); let mut exchanges: VecDeque<(String, String)> = VecDeque::with_capacity(keep.min(64)); let mut current_user: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { if let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) { if let Some(ref msg) = entry.message { let role = msg.role.as_deref().unwrap_or("unknown"); let Some(content_text) = msg.content.as_ref().and_then(extract_message_text) else { return; }; let Some(clean_text) = normalize_session_message(role, &content_text) else { return; }; match role { "user" => { current_user = Some(clean_text); } "assistant" => { if let Some(user_msg) = current_user.take() { if exchanges.len() == keep { exchanges.pop_front(); } exchanges.push_back((user_msg, clean_text)); } } _ => {} } } } })?; if exchanges.is_empty() { bail!("No exchanges found in session"); } // Format the context let mut context = String::new(); for (user_msg, assistant_msg) in exchanges { context.push_str("Human: "); context.push_str(&user_msg); context.push_str("\n\n"); context.push_str("Assistant: "); context.push_str(&assistant_msg); context.push_str("\n\n"); } // Remove trailing newlines while context.ends_with('\n') { context.pop(); } context.push('\n'); Ok(context) } /// Copy text to system clipboard. fn copy_to_clipboard(text: &str) -> Result<()> { if std::env::var("FLOW_NO_CLIPBOARD").is_ok() { return Ok(()); } #[cfg(target_os = "macos")] { let mut child = Command::new("pbcopy") .stdin(Stdio::piped()) .spawn() .context("failed to spawn pbcopy")?; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(text.as_bytes())?; } let status = child.wait()?; if !status.success() { bail!("pbcopy exited with status {}", status); } } #[cfg(target_os = "linux")] { // Try xclip first, then xsel let result = Command::new("xclip") .arg("-selection") .arg("clipboard") .stdin(Stdio::piped()) .spawn(); let mut child = match result { Ok(c) => c, Err(_) => Command::new("xsel") .arg("--clipboard") .arg("--input") .stdin(Stdio::piped()) .spawn() .context("failed to spawn xclip or xsel")?, }; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(text.as_bytes())?; } let status = child.wait()?; if !status.success() { bail!("clipboard command exited with status {}", status); } } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { bail!("clipboard not supported on this platform"); } Ok(()) } /// Strip <thinking> blocks from content (internal Claude processing). fn strip_thinking_blocks(s: &str) -> String { let mut remaining = s; let mut out = String::new(); loop { let Some(start) = remaining.find("<thinking>") else { out.push_str(remaining); break; }; out.push_str(&remaining[..start]); let after_start = &remaining[start + "<thinking>".len()..]; let Some(end) = after_start.find("</thinking>") else { break; }; remaining = &after_start[end + "</thinking>".len()..]; } out } fn truncate_str(s: &str, max: usize) -> String { let first_line = s.lines().next().unwrap_or(s); if first_line.chars().count() <= max { first_line.to_string() } else { let take_len = max.saturating_sub(3); let truncated: String = first_line.chars().take(take_len).collect(); format!("{}...", truncated) } } /// Format timestamp as relative time (e.g., "3 days ago", "2 hours ago"). fn format_relative_time(ts: &str) -> String { // Parse ISO 8601 timestamp: "2025-12-09T19:21:15.562Z" let parsed = chrono::DateTime::parse_from_rfc3339(ts).or_else(|_| { // Try without timezone chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.fZ") .map(|dt| dt.and_utc().fixed_offset()) }); let Ok(dt) = parsed else { return "unknown".to_string(); }; let now = chrono::Utc::now(); let duration = now.signed_duration_since(dt); let seconds = duration.num_seconds(); if seconds < 0 { return "just now".to_string(); } let minutes = duration.num_minutes(); let hours = duration.num_hours(); let days = duration.num_days(); let weeks = days / 7; if seconds < 60 { "just now".to_string() } else if minutes < 60 { format!("{}m ago", minutes) } else if hours < 24 { format!("{}h ago", hours) } else if days == 1 { "yesterday".to_string() } else if days < 7 { format!("{}d ago", days) } else if weeks < 4 { format!("{}w ago", weeks) } else { // Show date for older sessions dt.format("%b %d").to_string() } } /// Check if a session name looks auto-generated (from import). fn is_auto_generated_name(name: &str) -> bool { // Auto-generated names start with date like "20251215-" or "unknown-session" name.starts_with("202") && name.chars().nth(8) == Some('-') || name.starts_with("unknown-session") } fn extract_error_summary(entry: &JsonlEntry) -> Option<String> { let entry_type = entry.entry_type.as_deref(); let subtype = entry.subtype.as_deref(); let level = entry.level.as_deref(); let is_error = level == Some("error") || entry_type == Some("error") || subtype.map(|s| s.contains("error")).unwrap_or(false) || entry.error.is_some(); if !is_error { return None; } let mut summary = if let Some(sub) = subtype { format!("error: {}", sub) } else if let Some(kind) = entry_type { format!("error: {}", kind) } else { "error".to_string() }; if let Some(err) = &entry.error { let msg = err .get("message") .and_then(|v| v.as_str()) .or_else(|| err.get("error").and_then(|v| v.as_str())); if let Some(msg) = msg { summary = format!("{}: {}", summary, msg); } } Some(summary) } fn extract_codex_user_message(entry: &CodexEntry) -> Option<String> { let entry_type = entry.entry_type.as_deref(); if entry_type == Some("response_item") { let payload = entry.payload.as_ref()?; if payload.get("type").and_then(|v| v.as_str()) != Some("message") { return None; } if payload.get("role").and_then(|v| v.as_str()) != Some("user") { return None; } let text = extract_codex_content_text(payload.get("content")?)?; return normalize_session_message("user", &text); } if entry_type == Some("event_msg") { let payload = entry.payload.as_ref()?; let payload_type = payload.get("type").and_then(|v| v.as_str()); if payload_type == Some("user_message") { return payload .get("message") .and_then(|v| v.as_str()) .and_then(|s| normalize_session_message("user", s)); } } if entry_type == Some("message") && entry.role.as_deref() == Some("user") { if let Some(content) = entry.content.as_ref() { let text = extract_codex_content_text(content)?; return normalize_session_message("user", &text); } } None } fn extract_codex_error_summary(entry: &CodexEntry) -> Option<String> { let entry_type = entry.entry_type.as_deref(); let payload = entry.payload.as_ref(); let is_error = entry_type == Some("error") || payload .and_then(|p| p.get("type").and_then(|v| v.as_str())) .map(|t| t.contains("error")) .unwrap_or(false); if !is_error { return None; } let mut summary = if let Some(t) = entry_type { format!("error: {}", t) } else { "error".to_string() }; if let Some(p) = payload { if let Some(msg) = p.get("message").and_then(|v| v.as_str()) { summary = format!("{}: {}", summary, msg); } } Some(summary) } fn extract_codex_content_text(value: &serde_json::Value) -> Option<String> { match value { serde_json::Value::String(s) => Some(s.clone()), serde_json::Value::Array(arr) => { let mut parts = Vec::new(); for item in arr { if let Some(text) = item.get("text").and_then(|v| v.as_str()) { parts.push(text.to_string()); continue; } if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) { parts.push(text.to_string()); continue; } if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) { parts.push(text.to_string()); continue; } } if parts.is_empty() { None } else { Some(parts.join("\n")) } } _ => None, } } /// Clean up a summary string - remove noise, paths, special chars. fn clean_summary(s: &str) -> String { // Take first meaningful line (skip empty lines and lines starting with special chars) let meaningful_line = s .lines() .map(|l| l.trim()) .find(|l| { !l.is_empty() && !l.starts_with('~') && !l.starts_with('/') && !l.starts_with('>') && !l.starts_with('❯') && !l.starts_with('$') && !l.starts_with('#') && !l.starts_with("Error:") && !l.starts_with("<INSTRUCTIONS>") && !l.starts_with("## Skills") }) .or_else(|| s.lines().find(|l| !l.trim().is_empty())) .unwrap_or(s); // Clean up the line meaningful_line.trim().replace('\t', " ").replace(" ", " ") } const GEMINI_API_URL: &str = "https://generativelanguage.googleapis.com/v1beta/models"; const DEFAULT_GEMINI_MODEL: &str = "gemini-1.5-flash"; const DEFAULT_SUMMARY_AGE_MINUTES: i64 = 45; const DEFAULT_SUMMARY_MAX_CHARS: usize = 12_000; const DEFAULT_HANDOFF_MAX_CHARS: usize = 6_000; fn get_session_summaries_path(project_path: &PathBuf) -> PathBuf { project_path .join(".ai") .join("internal") .join("session-summaries.json") } fn load_session_summaries(project_path: &PathBuf) -> Result<SessionSummaries> { let path = get_session_summaries_path(project_path); if !path.exists() { return Ok(SessionSummaries::default()); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; serde_json::from_str(&content).context("failed to parse session-summaries.json") } fn save_session_summaries(project_path: &PathBuf, summaries: &SessionSummaries) -> Result<()> { let path = get_session_summaries_path(project_path); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = serde_json::to_string_pretty(summaries)?; fs::write(&path, content)?; Ok(()) } fn summary_key(session: &CrossProjectSession) -> String { let provider = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "ai", }; format!("{}:{}", provider, session.session_id) } fn get_summary_cache_entry<'a>( cache: &'a mut HashMap<PathBuf, SummaryCacheEntry>, project_path: &PathBuf, ) -> Result<&'a mut SummaryCacheEntry> { if !cache.contains_key(project_path) { let store = load_session_summaries(project_path)?; cache.insert( project_path.clone(), SummaryCacheEntry { store, dirty: false, }, ); } Ok(cache.get_mut(project_path).expect("cache entry must exist")) } fn summary_age_minutes() -> i64 { std::env::var("FLOW_SESSIONS_SUMMARY_AGE_MINUTES") .ok() .and_then(|v| v.parse::<i64>().ok()) .unwrap_or(DEFAULT_SUMMARY_AGE_MINUTES) } fn summary_max_chars() -> usize { std::env::var("FLOW_SESSIONS_SUMMARY_MAX_CHARS") .ok() .and_then(|v| v.parse::<usize>().ok()) .unwrap_or(DEFAULT_SUMMARY_MAX_CHARS) } fn handoff_max_chars() -> usize { std::env::var("FLOW_SESSIONS_HANDOFF_MAX_CHARS") .ok() .and_then(|v| v.parse::<usize>().ok()) .unwrap_or(DEFAULT_HANDOFF_MAX_CHARS) } fn gemini_model() -> String { std::env::var("GEMINI_MODEL").unwrap_or_else(|_| DEFAULT_GEMINI_MODEL.to_string()) } fn get_gemini_api_key() -> Result<String> { if let Ok(key) = std::env::var("GEMINI_API_KEY") { if !key.trim().is_empty() { return Ok(key); } } if let Ok(key) = std::env::var("GOOGLE_API_KEY") { if !key.trim().is_empty() { return Ok(key); } } if let Ok(Some(key)) = crate::env::get_personal_env_var("GEMINI_API_KEY") { if !key.trim().is_empty() { return Ok(key); } } if let Ok(Some(key)) = crate::env::get_personal_env_var("GOOGLE_API_KEY") { if !key.trim().is_empty() { return Ok(key); } } bail!("Missing GEMINI_API_KEY/GOOGLE_API_KEY (set env var or add to personal env)") } fn truncate_for_summary(context: &str) -> String { let max_chars = summary_max_chars(); if context.chars().count() <= max_chars { return context.to_string(); } let start = context.chars().count().saturating_sub(max_chars); context.chars().skip(start).collect() } fn truncate_for_handoff(context: &str) -> String { let max_chars = handoff_max_chars(); if context.chars().count() <= max_chars { return context.to_string(); } let start = context.chars().count().saturating_sub(max_chars); context.chars().skip(start).collect() } fn should_summarize(last_ts: &str) -> bool { let Ok(ts) = chrono::DateTime::parse_from_rfc3339(last_ts) else { return false; }; let age = chrono::Utc::now().signed_duration_since(ts); age.num_minutes() >= summary_age_minutes() } fn summarize_session_with_gemini(context: &str) -> Result<SessionSummary> { let api_key = get_gemini_api_key()?; let model = gemini_model(); let prompt = format!( "Summarize this coding session. Return JSON only with fields:\n\ summary: short 1-2 sentence summary (<= 220 chars), no boilerplate\n\ chapters: array of 3-8 items, each with title (3-8 words) and summary (1-2 sentences)\n\ \nSession:\n{}", truncate_for_summary(context) ); let client = crate::http_client::blocking_with_timeout(Duration::from_secs(30)) .context("failed to create HTTP client")?; let url = format!( "{}/{}:generateContent?key={}", GEMINI_API_URL, model, api_key ); let payload = json!({ "contents": [ { "role": "user", "parts": [ { "text": prompt } ] } ], "generationConfig": { "temperature": 0.2, "maxOutputTokens": 700, "responseMimeType": "application/json" } }); let resp = client .post(&url) .json(&payload) .send() .context("failed to call Gemini API")?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); bail!("Gemini API error {}: {}", status, text); } let parsed: GeminiResponse = resp.json().context("failed to parse Gemini response")?; let content = parsed .candidates .get(0) .and_then(|c| c.content.parts.get(0)) .and_then(|p| p.text.as_deref()) .unwrap_or("") .trim(); if content.is_empty() { bail!("Gemini returned empty summary"); } let summary_payload = parse_summary_response(content)?; Ok(SessionSummary { summary: summary_payload.summary, chapters: summary_payload.chapters, session_last_timestamp: None, model, updated_at: chrono::Utc::now().to_rfc3339(), }) } fn summarize_handoff_with_gemini(context: &str) -> Result<String> { let api_key = get_gemini_api_key()?; let model = gemini_model(); let prompt = format!( "Create a concise handoff for another coding agent. Plain text only.\n\ Include these sections:\n\ - Goal\n\ - Current state\n\ - Key files/paths\n\ - Pending tasks / next steps\n\ - Gotchas / blockers\n\ Keep it brief (<= 12 lines). No preamble.\n\ \nSession:\n{}", truncate_for_handoff(context) ); let client = crate::http_client::blocking_with_timeout(Duration::from_secs(30)) .context("failed to create HTTP client")?; let url = format!( "{}/{}:generateContent?key={}", GEMINI_API_URL, model, api_key ); let payload = json!({ "contents": [ { "role": "user", "parts": [ { "text": prompt } ] } ], "generationConfig": { "temperature": 0.2, "maxOutputTokens": 600, "responseMimeType": "text/plain" } }); let resp = client .post(&url) .json(&payload) .send() .context("failed to call Gemini API")?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); bail!("Gemini API error {}: {}", status, text); } let parsed: GeminiResponse = resp.json().context("failed to parse Gemini response")?; let content = parsed .candidates .get(0) .and_then(|c| c.content.parts.get(0)) .and_then(|p| p.text.as_deref()) .unwrap_or("") .trim(); if content.is_empty() { bail!("Gemini returned empty handoff"); } Ok(content.to_string()) } fn parse_summary_response(content: &str) -> Result<SessionSummaryResponse> { if let Ok(parsed) = serde_json::from_str::<SessionSummaryResponse>(content) { return Ok(parsed); } let json_blob = extract_json_object(content) .ok_or_else(|| anyhow::anyhow!("summary response was not valid JSON"))?; serde_json::from_str(&json_blob).context("failed to parse summary JSON") } fn extract_json_object(s: &str) -> Option<String> { let start = s.find('{')?; let end = s.rfind('}')?; if end <= start { return None; } Some(s[start..=end].to_string()) } #[derive(Debug, Deserialize)] struct GeminiResponse { candidates: Vec<GeminiCandidate>, } #[derive(Debug, Deserialize)] struct GeminiCandidate { content: GeminiContent, } #[derive(Debug, Deserialize)] struct GeminiContent { parts: Vec<GeminiPart>, } #[derive(Debug, Deserialize)] struct GeminiPart { text: Option<String>, } #[derive(Debug, Deserialize)] struct SessionSummaryResponse { summary: String, chapters: Vec<SessionChapter>, } fn get_display_summary( session: &CrossProjectSession, cache: &mut HashMap<PathBuf, SummaryCacheEntry>, ) -> Result<Option<String>> { let key = summary_key(session); let entry = get_summary_cache_entry(cache, &session.project_path)?; if let Some(summary) = entry.store.summaries.get(&key) { if !summary.summary.trim().is_empty() { return Ok(Some(summary.summary.clone())); } } Ok(None) } /// Return provider:session_id for the most recent session in the project. pub fn get_latest_session_ref_for_path(project_path: &PathBuf) -> Result<Option<String>> { let sessions = read_sessions_for_path(Provider::All, project_path)?; Ok(sessions .first() .map(|session| format_session_ref(session, true))) } /// Return full message history for the latest session matching a path. pub fn get_latest_session_history_for_path( project_path: &PathBuf, ) -> Result<Option<SessionHistory>> { let sessions = read_sessions_for_path(Provider::All, project_path)?; let Some(session) = sessions.first() else { return Ok(None); }; let session_messages = read_session_messages_for_path(project_path, &session.session_id, session.provider)?; let provider = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "unknown", }; let started_at = session_messages .started_at .clone() .or_else(|| session.timestamp.clone()); let last_message_at = session_messages .last_message_at .clone() .or_else(|| session.last_message_at.clone()) .or_else(|| started_at.clone()); Ok(Some(SessionHistory { session_id: session.session_id.clone(), provider: provider.to_string(), started_at, last_message_at, messages: session_messages.messages, })) } fn maybe_update_summary( session: &CrossProjectSession, cache: &mut HashMap<PathBuf, SummaryCacheEntry>, ) -> Result<()> { let Some(last_ts) = get_session_last_timestamp_for_session(session)? else { return Ok(()); }; if !should_summarize(&last_ts) { return Ok(()); } let key = summary_key(session); let entry = get_summary_cache_entry(cache, &session.project_path)?; if let Some(existing) = entry.store.summaries.get(&key) { if existing.session_last_timestamp.as_deref() == Some(last_ts.as_str()) { return Ok(()); } } let (context, context_last_ts) = read_cross_project_context(session, None, None)?; if context.trim().is_empty() { return Ok(()); } let mut summary = summarize_session_with_gemini(&context)?; summary.session_last_timestamp = Some(context_last_ts.unwrap_or(last_ts)); entry.store.summaries.insert(key, summary); entry.dirty = true; Ok(()) } fn save_summary_cache(cache: &mut HashMap<PathBuf, SummaryCacheEntry>) -> Result<()> { for (project_path, entry) in cache.iter_mut() { if entry.dirty { save_session_summaries(project_path, &entry.store)?; entry.dirty = false; } } Ok(()) } fn get_session_last_timestamp_for_session(session: &CrossProjectSession) -> Result<Option<String>> { if session.provider == Provider::Codex { let session_file = session .session_path .clone() .or_else(|| find_codex_session_file(&session.session_id)); let Some(session_file) = session_file else { return Ok(None); }; return get_codex_last_timestamp(&session_file); } get_session_last_timestamp_for_path( &session.session_id, session.provider, &session.project_path, ) } /// Resume a session by name or ID. fn resume_session(session: Option<String>, path: Option<String>, provider: Provider) -> Result<()> { let index = load_index()?; let sessions = read_sessions_for_target(provider, path.as_deref())?; let explicit_session_requested = session.is_some(); let default_provider = if provider == Provider::All { Provider::Claude } else { provider }; let (session_id, session_provider) = match session { Some(s) => { // Check if it's a saved name if let Some(saved) = index.sessions.get(&s) { // Find the provider for this session let prov = sessions .iter() .find(|sess| sess.session_id == saved.id) .map(|sess| sess.provider) .unwrap_or(default_provider); (saved.id.clone(), prov) } else if s.len() >= 8 { // Might be a session ID or prefix if let Some(sess) = sessions.iter().find(|sess| sess.session_id.starts_with(&s)) { (sess.session_id.clone(), sess.provider) } else { // Assume it's a full ID for requested provider. (s, default_provider) } } else { // Try numeric index (1-based) if let Ok(idx) = s.parse::<usize>() { if idx > 0 && idx <= sessions.len() { let sess = &sessions[idx - 1]; (sess.session_id.clone(), sess.provider) } else { bail!("Session index {} out of range", idx); } } else { bail!("Session '{}' not found", s); } } } None => { // Resume most recent let sess = sessions .first() .ok_or_else(|| anyhow::anyhow!("No sessions found for this project"))?; (sess.session_id.clone(), sess.provider) } }; let has_tty = io::stdin().is_terminal() && io::stdout().is_terminal(); if !has_tty { match session_provider { Provider::Codex => { bail!( "codex resume requires an interactive terminal (TTY); run this in a terminal tab (e.g. Zed/Ghostty)" ); } Provider::Claude => { bail!( "claude resume requires an interactive terminal (TTY); run this in a terminal tab (e.g. Zed/Ghostty)" ); } Provider::Cursor => { bail!( "cursor transcripts are readable only; use `f cursor list`, `f cursor copy`, or `f cursor context`" ); } Provider::All => {} } } if session_provider == Provider::Cursor { bail!( "cursor transcripts are readable only; use `f cursor list`, `f cursor copy`, or `f cursor context`" ); } println!( "Resuming session {}...", &session_id[..8.min(session_id.len())] ); let launched = launch_session(&session_id, session_provider)?; if launched { return Ok(()); } // Claude occasionally cannot reopen older local transcript IDs. // For explicit IDs, do not auto-fallback to --continue because that can // open a different conversation and hide the failure. if session_provider == Provider::Claude { eprintln!( "Claude could not resume session {}.", &session_id[..8.min(session_id.len())] ); if explicit_session_requested { bail!( "failed to resume exact claude session {}. refusing fallback to `claude --continue` to avoid opening the wrong session", session_id ); } if !has_tty { bail!( "failed to resume claude session {} (non-interactive shell; fallback continue unavailable)", session_id ); } eprintln!("Falling back to `claude --continue` in this directory..."); let continued = launch_claude_continue()?; if continued { return Ok(()); } bail!( "failed to resume claude session {} and fallback `claude --continue` also failed", session_id ); } bail!( "failed to resume {} session {}", provider_name(session_provider), session_id ); } /// Save a session with a name. fn save_session(name: &str, id: Option<String>) -> Result<()> { let session_id = match id { Some(id) => id, None => get_most_recent_session_id()? .ok_or_else(|| anyhow::anyhow!("No sessions found. Start an AI session first."))?, }; let mut index = load_index()?; // Check if name already exists if index.sessions.contains_key(name) { bail!( "Session name '{}' already exists. Use a different name or remove it first.", name ); } let session_provider = read_sessions_for_project(Provider::All)? .into_iter() .find(|session| session.session_id == session_id) .map(|session| session.provider) .unwrap_or(Provider::Claude); let saved = SavedSession { id: session_id.clone(), provider: provider_name(session_provider).to_string(), description: None, saved_at: chrono::Utc::now().to_rfc3339(), last_resumed: None, }; index.sessions.insert(name.to_string(), saved); save_index(&index)?; println!("Saved session as '{}'", name); println!(" ID: {}", &session_id[..8.min(session_id.len())]); println!("\nResume with: f ai resume {}", name); Ok(()) } /// Open or create notes for a session. fn open_notes(session: &str) -> Result<()> { let index = load_index()?; // Find the session ID let session_id = if let Some(saved) = index.sessions.get(session) { saved.id.clone() } else { // Might be a direct ID session.to_string() }; let notes_dir = get_notes_dir()?; fs::create_dir_all(¬es_dir)?; let note_file = notes_dir.join(format!("{}.md", session)); // Create the file if it doesn't exist if !note_file.exists() { let template = format!( "# Session: {}\n\nSession ID: {}\n\n## Notes\n\n", session, &session_id[..8.min(session_id.len())] ); fs::write(¬e_file, template)?; } // Open in $EDITOR let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); let status = Command::new(&editor) .arg(¬e_file) .status() .with_context(|| format!("failed to open editor: {}", editor))?; if !status.success() { bail!("editor exited with status {}", status); } Ok(()) } /// Remove a saved session from tracking. fn remove_session(session: &str) -> Result<()> { let mut index = load_index()?; if index.sessions.remove(session).is_some() { save_index(&index)?; println!("Removed session '{}'", session); // Also remove notes if they exist let notes_dir = get_notes_dir()?; let note_file = notes_dir.join(format!("{}.md", session)); if note_file.exists() { fs::remove_file(¬e_file)?; println!("Removed notes file"); } } else { bail!("Session '{}' not found in saved sessions", session); } Ok(()) } /// Initialize the .ai folder structure. fn init_ai_folder() -> Result<()> { let ai_dir = std::env::current_dir()?.join(".ai"); let internal_dir = ai_dir.join("internal"); let sessions_dir = internal_dir.join("sessions").join("claude"); let notes_dir = sessions_dir.join("notes"); fs::create_dir_all(¬es_dir)?; // Create empty index.json if it doesn't exist let index_path = sessions_dir.join("index.json"); if !index_path.exists() { let index = SessionIndex::default(); let content = serde_json::to_string_pretty(&index)?; fs::write(&index_path, content)?; } println!("Initialized .ai folder structure:"); println!(" .ai/internal/sessions/claude/index.json"); println!(" .ai/internal/sessions/claude/notes/"); Ok(()) } /// Ensure .ai/internal is in the project's .gitignore to prevent session leaks. fn ensure_gitignore() -> Result<()> { let cwd = std::env::current_dir().context("failed to get current directory")?; let gitignore_path = cwd.join(".gitignore"); if gitignore_path.exists() { let content = fs::read_to_string(&gitignore_path).unwrap_or_default(); // Check if .ai/internal is already ignored let already_ignored = content.lines().any(|line| { let trimmed = line.trim(); trimmed == ".ai/internal" || trimmed == ".ai/internal/" || trimmed == "/.ai/internal" || trimmed == "/.ai/internal/" }); if !already_ignored { // Append .ai/internal to gitignore let mut file = fs::OpenOptions::new().append(true).open(&gitignore_path)?; // Add newline if file doesn't end with one if !content.ends_with('\n') && !content.is_empty() { writeln!(file)?; } writeln!(file, ".ai/internal/")?; } } else { // Create .gitignore with .ai/internal fs::write(&gitignore_path, ".ai/internal/\n")?; } Ok(()) } /// Silently auto-import any new Claude sessions (called by list_sessions). fn auto_import_sessions() -> Result<()> { // Ensure .ai is in .gitignore to prevent session leaks let _ = ensure_gitignore(); // Silently ensure .ai folder exists let sessions_dir = get_ai_sessions_dir()?; if !sessions_dir.exists() { fs::create_dir_all(&sessions_dir)?; let index_path = sessions_dir.join("index.json"); fs::write(&index_path, "{\"sessions\":{}}")?; } let sessions = read_sessions_for_project(Provider::Claude)?; if sessions.is_empty() { return Ok(()); } let mut index = load_index()?; let mut changed = false; for session in &sessions { // Skip if already imported if index.sessions.values().any(|s| s.id == session.session_id) { continue; } let name = generate_session_name(session, &index); let provider_str = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "claude", }; let saved = SavedSession { id: session.session_id.clone(), provider: provider_str.to_string(), description: session .first_message .as_ref() .or(session.error_summary.as_ref()) .map(|m| { if m.len() > 100 { let end = floor_char_boundary(m, 97); format!("{}...", &m[..end]) } else { m.clone() } }), saved_at: chrono::Utc::now().to_rfc3339(), last_resumed: None, }; index.sessions.insert(name, saved); changed = true; } if changed { save_index(&index)?; } Ok(()) } /// Import all existing Claude sessions for this project. fn import_sessions() -> Result<()> { // Ensure .ai folder exists init_ai_folder()?; println!(); let sessions = read_sessions_for_project(Provider::Claude)?; if sessions.is_empty() { println!("No Claude sessions found for this project."); return Ok(()); } let mut index = load_index()?; let mut imported = 0; let mut skipped = 0; for session in &sessions { // Check if already imported if index.sessions.values().any(|s| s.id == session.session_id) { skipped += 1; continue; } // Generate a name from timestamp and first few words of first message let name = generate_session_name(session, &index); let provider_str = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "claude", }; let saved = SavedSession { id: session.session_id.clone(), provider: provider_str.to_string(), description: session .first_message .as_ref() .or(session.error_summary.as_ref()) .map(|m| { if m.len() > 100 { let end = floor_char_boundary(m, 97); format!("{}...", &m[..end]) } else { m.clone() } }), saved_at: chrono::Utc::now().to_rfc3339(), last_resumed: None, }; index.sessions.insert(name.clone(), saved); imported += 1; let id_short = &session.session_id[..8.min(session.session_id.len())]; println!(" Imported: {} ({})", name, id_short); } save_index(&index)?; println!(); println!( "Imported {} sessions, skipped {} (already exists)", imported, skipped ); Ok(()) } /// Generate a unique name for a session based on its content. fn generate_session_name(session: &AiSession, index: &SessionIndex) -> String { // Try to create a name from date + first words of message let date_part = session .timestamp .as_deref() .map(|ts| ts[..10].replace('-', "")) // "20251209" .unwrap_or_else(|| "unknown".to_string()); let words_part = session .first_message .as_deref() .or(session.error_summary.as_deref()) .map(|msg| { // Extract first few meaningful words let words: Vec<&str> = msg .split_whitespace() .filter(|w| w.len() > 2 && !w.starts_with('/') && !w.starts_with('~')) .take(3) .collect(); if words.is_empty() { "session".to_string() } else { words .join("-") .to_lowercase() .chars() .filter(|c| c.is_alphanumeric() || *c == '-') .take(20) .collect() } }) .unwrap_or_else(|| "session".to_string()); let base_name = format!("{}-{}", date_part, words_part); // Ensure uniqueness if !index.sessions.contains_key(&base_name) { return base_name; } // Add suffix if name exists for i in 2..100 { let name = format!("{}-{}", base_name, i); if !index.sessions.contains_key(&name) { return name; } } // Fallback to UUID prefix format!("{}-{}", base_name, &session.session_id[..8]) } // ============================================================================ // Cross-project session search (f sessions) // ============================================================================ use crate::cli::SessionsOpts; /// Session with project info for cross-project display. #[derive(Debug, Clone)] struct CrossProjectSession { session_id: String, provider: Provider, project_path: PathBuf, project_name: String, timestamp: Option<String>, first_message: Option<String>, error_summary: Option<String>, session_path: Option<PathBuf>, } #[derive(Debug, Serialize, Deserialize, Default)] struct SessionSummaries { summaries: HashMap<String, SessionSummary>, } #[derive(Debug, Serialize, Deserialize, Clone)] struct SessionSummary { summary: String, chapters: Vec<SessionChapter>, session_last_timestamp: Option<String>, model: String, updated_at: String, } #[derive(Debug, Serialize, Deserialize, Clone)] struct SessionChapter { title: String, summary: String, } struct SummaryCacheEntry { store: SessionSummaries, dirty: bool, } /// Consumed checkpoint tracking - stored in target project's .ai folder. #[derive(Debug, Serialize, Deserialize, Default)] struct ConsumedCheckpoints { /// Map of source project path -> last consumed timestamp consumed: HashMap<String, ConsumedEntry>, } #[derive(Debug, Serialize, Deserialize, Clone)] struct ConsumedEntry { /// Last consumed timestamp from that project last_timestamp: String, /// When we consumed it consumed_at: String, /// Session ID we consumed from session_id: String, } /// Run cross-project session search. pub fn run_sessions(opts: &SessionsOpts) -> Result<()> { let provider = match opts.provider.to_lowercase().as_str() { "claude" => Provider::Claude, "codex" => Provider::Codex, "cursor" => Provider::Cursor, _ => Provider::All, }; let sessions = scan_all_project_sessions(provider)?; let mut summary_cache: HashMap<PathBuf, SummaryCacheEntry> = HashMap::new(); let summarize_enabled = opts.summarize && get_gemini_api_key().is_ok(); if sessions.is_empty() { println!("No AI sessions found across projects."); return Ok(()); } if opts.summarize && !summarize_enabled { println!("GEMINI_API_KEY/GOOGLE_API_KEY not set; skipping session summaries."); } if summarize_enabled { for session in &sessions { let _ = maybe_update_summary(session, &mut summary_cache); } let _ = save_summary_cache(&mut summary_cache); } if opts.list { // Just list, don't fuzzy search println!("AI Sessions across projects:\n"); for session in &sessions { let relative_time = session .timestamp .as_deref() .map(format_relative_time) .unwrap_or_else(|| "unknown".to_string()); let summary = get_display_summary(session, &mut summary_cache)? .or_else(|| { session .first_message .as_deref() .or(session.error_summary.as_deref()) .map(|s| s.to_string()) }) .map(|s| truncate_str(&clean_summary(&s), 50)) .unwrap_or_default(); let provider_tag = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "ai", }; println!( "{} | {} | {} | {}", session.project_name, provider_tag, relative_time, summary ); } return Ok(()); } // Build fzf entries let entries: Vec<(String, &CrossProjectSession)> = sessions .iter() .filter(|s| s.timestamp.is_some() || s.first_message.is_some() || s.error_summary.is_some()) .map(|session| { let relative_time = session .timestamp .as_deref() .map(format_relative_time) .unwrap_or_else(|| "".to_string()); let summary = get_display_summary(session, &mut summary_cache) .unwrap_or(None) .or_else(|| { session .first_message .as_deref() .or(session.error_summary.as_deref()) .map(|s| s.to_string()) }) .map(|s| truncate_str(&clean_summary(&s), 40)) .unwrap_or_default(); let provider_tag = match session.provider { Provider::Claude => "claude", Provider::Codex => "codex", Provider::Cursor => "cursor", Provider::All => "", }; let display = format!( "{} | {} | {} | {}", session.project_name, provider_tag, relative_time, summary ); (display, session) }) .collect(); if entries.is_empty() { println!("No sessions with content found."); return Ok(()); } // Check for fzf if which::which("fzf").is_err() { println!("fzf not found – install it for fuzzy selection."); println!("\nSessions:"); for (display, _) in &entries { println!("{}", display); } return Ok(()); } // Run fzf let mut child = Command::new("fzf") .arg("--prompt") .arg("sessions> ") .arg("--ansi") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for (display, _) in &entries { writeln!(stdin, "{}", display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(()); } let selection = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let selection = selection.trim(); if selection.is_empty() { return Ok(()); } // Find selected session let Some((_, session)) = entries.iter().find(|(d, _)| d == selection) else { bail!("Session not found"); }; // Get context since last consumed checkpoint (or full if --full) let context = get_cross_project_context(session, opts.count, opts.full)?; if context.is_empty() { if opts.full { println!("No context found in session."); } else { println!("No new context since last consumption. Use --full for entire session."); } return Ok(()); } let output = if opts.handoff { summarize_handoff_with_gemini(&context)? } else { context }; // Copy to clipboard copy_to_clipboard(&output)?; let explains = if opts.handoff { "handoff summary" } else { "context" }; let line_count = output.lines().count(); println!( "Copied {} from {} ({} lines) to clipboard", explains, session.project_name, line_count ); // Save consumed checkpoint save_consumed_checkpoint(session)?; Ok(()) } /// Scan all projects for AI sessions. fn scan_all_project_sessions(provider: Provider) -> Result<Vec<CrossProjectSession>> { let mut all_sessions = Vec::new(); // Scan Claude projects if provider == Provider::Claude || provider == Provider::All { let claude_dir = get_claude_projects_dir(); if claude_dir.exists() { if let Ok(entries) = fs::read_dir(&claude_dir) { for entry in entries.flatten() { let project_folder = entry.path(); if project_folder.is_dir() { let project_name = extract_project_name(&project_folder); let project_path = folder_to_path(&project_folder); if let Ok(sessions) = scan_project_sessions(&project_folder, Provider::Claude) { for session in sessions { all_sessions.push(CrossProjectSession { session_id: session.session_id, provider: Provider::Claude, project_path: project_path.clone(), project_name: project_name.clone(), timestamp: session.timestamp, first_message: session.first_message, error_summary: session.error_summary, session_path: None, }); } } } } } } } // Scan Codex sessions (new format) if provider == Provider::Codex || provider == Provider::All { let codex_dir = get_codex_sessions_dir(); if codex_dir.exists() { for file_path in collect_codex_session_files(&codex_dir) { let filename = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let Some((session, cwd)) = parse_codex_session_file(&file_path, filename) else { continue; }; let Some(project_path) = cwd else { continue; }; let project_name = project_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); all_sessions.push(CrossProjectSession { session_id: session.session_id, provider: Provider::Codex, project_path, project_name, timestamp: session.timestamp, first_message: session.first_message, error_summary: session.error_summary, session_path: Some(file_path), }); } } else { // Fallback to legacy Codex projects layout let codex_dir = get_codex_projects_dir(); if codex_dir.exists() { if let Ok(entries) = fs::read_dir(&codex_dir) { for entry in entries.flatten() { let project_folder = entry.path(); if project_folder.is_dir() { let project_name = extract_project_name(&project_folder); let project_path = folder_to_path(&project_folder); if let Ok(sessions) = scan_project_sessions(&project_folder, Provider::Codex) { for session in sessions { all_sessions.push(CrossProjectSession { session_id: session.session_id, provider: Provider::Codex, project_path: project_path.clone(), project_name: project_name.clone(), timestamp: session.timestamp, first_message: session.first_message, error_summary: session.error_summary, session_path: None, }); } } } } } } } } // Scan Cursor agent transcripts. if provider == Provider::Cursor || provider == Provider::All { let cursor_dir = get_cursor_projects_dir(); if cursor_dir.exists() { if let Ok(entries) = fs::read_dir(&cursor_dir) { for entry in entries.flatten() { let project_dir = entry.path(); if !project_dir.is_dir() { continue; } let Some(project_key) = project_dir.file_name().and_then(|name| name.to_str()) else { continue; }; let Some(project_path) = decode_cursor_project_path(project_key) else { continue; }; let project_name = project_path .file_name() .and_then(|name| name.to_str()) .unwrap_or(project_key) .to_string(); for file_path in collect_cursor_project_session_files(&project_dir) { let filename = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let Some(session) = parse_cursor_session_file(&file_path, filename) else { continue; }; all_sessions.push(CrossProjectSession { session_id: session.session_id, provider: Provider::Cursor, project_path: project_path.clone(), project_name: project_name.clone(), timestamp: session.timestamp, first_message: session.first_message, error_summary: session.error_summary, session_path: Some(file_path), }); } } } } } // Sort by timestamp descending (most recent first) all_sessions.sort_by(|a, b| { let ts_a = a.timestamp.as_deref().unwrap_or(""); let ts_b = b.timestamp.as_deref().unwrap_or(""); ts_b.cmp(ts_a) }); Ok(all_sessions) } /// Scan a project folder for sessions. fn scan_project_sessions(project_folder: &PathBuf, provider: Provider) -> Result<Vec<AiSession>> { let mut sessions = Vec::new(); let entries = fs::read_dir(project_folder) .with_context(|| format!("failed to read {}", project_folder.display()))?; for entry in entries.flatten() { let path = entry.path(); if path.extension().map(|e| e == "jsonl").unwrap_or(false) { let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if filename.starts_with("agent-") { continue; } if let Some(session) = parse_session_file(&path, filename, provider) { sessions.push(session); } } } // Sort by timestamp descending sessions.sort_by(|a, b| { let ts_a = a.timestamp.as_deref().unwrap_or(""); let ts_b = b.timestamp.as_deref().unwrap_or(""); ts_b.cmp(ts_a) }); Ok(sessions) } /// Extract a friendly project name from the folder name. fn extract_project_name(folder: &PathBuf) -> String { folder .file_name() .and_then(|s| s.to_str()) .map(|s| { // The folder name is path with / replaced by - // Extract just the last component as project name s.rsplit('-').next().unwrap_or(s).to_string() }) .unwrap_or_else(|| "unknown".to_string()) } /// Convert folder name back to approximate path. fn folder_to_path(folder: &PathBuf) -> PathBuf { let name = folder.file_name().and_then(|s| s.to_str()).unwrap_or(""); // Folder name is path with / replaced by - // This is a heuristic - convert leading - to / PathBuf::from(name.replacen('-', "/", name.matches('-').count())) } /// Get context from a cross-project session since last consumed checkpoint. fn get_cross_project_context( session: &CrossProjectSession, count: Option<usize>, full: bool, ) -> Result<String> { // If full mode, ignore checkpoints let since_ts = if full { None } else { // Load consumed checkpoints for current project let cwd = std::env::current_dir()?; let consumed = load_consumed_checkpoints(&cwd)?; let source_key = session.project_path.to_string_lossy().to_string(); consumed .consumed .get(&source_key) .map(|e| e.last_timestamp.clone()) }; // Read context since checkpoint (or full if since_ts is None) let (context, _last_ts) = read_cross_project_context(session, since_ts.as_deref(), count)?; Ok(context) } /// Read context from a cross-project session. fn read_cross_project_context( session: &CrossProjectSession, since_ts: Option<&str>, max_count: Option<usize>, ) -> Result<(String, Option<String>)> { if session.provider == Provider::Codex { let session_file = session .session_path .clone() .or_else(|| find_codex_session_file(&session.session_id)); let Some(session_file) = session_file else { bail!( "Session file not found for Codex session {}", session.session_id ); }; return read_codex_cross_project_context(session, &session_file, since_ts, max_count); } if session.provider == Provider::Cursor { let session_file = session .session_path .clone() .or_else(|| find_cursor_session_file(&session.session_id)); let Some(session_file) = session_file else { bail!( "Session file not found for Cursor session {}", session.session_id ); }; return read_cursor_cross_project_context(session, &session_file, since_ts, max_count); } let projects_dir = match session.provider { Provider::Claude | Provider::All => get_claude_projects_dir(), Provider::Codex => get_codex_projects_dir(), Provider::Cursor => get_cursor_projects_dir(), }; let project_folder = session.project_path.to_string_lossy().replace('/', "-"); let session_file = projects_dir .join(&project_folder) .join(format!("{}.jsonl", session.session_id)); if !session_file.exists() { bail!("Session file not found: {}", session_file.display()); } // Collect exchanges after the checkpoint timestamp let mut exchanges: Vec<(String, String, String)> = Vec::new(); let mut current_user: Option<String> = None; let mut current_ts: Option<String> = None; let mut last_ts: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { if let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) { let entry_ts = entry.timestamp.clone(); // Skip entries before checkpoint if let (Some(since), Some(ts)) = (since_ts, &entry_ts) { if ts.as_str() <= since { return; } } if let Some(ref msg) = entry.message { let role = msg.role.as_deref().unwrap_or("unknown"); let Some(content_text) = msg.content.as_ref().and_then(extract_message_text) else { return; }; let Some(clean_text) = normalize_session_message(role, &content_text) else { return; }; match role { "user" => { current_user = Some(clean_text); current_ts = entry_ts.clone(); } "assistant" => { if let Some(user_msg) = current_user.take() { let ts = current_ts.take().or(entry_ts.clone()).unwrap_or_default(); exchanges.push((user_msg, clean_text, ts.clone())); last_ts = Some(ts); } } _ => {} } } if entry_ts.is_some() { last_ts = entry_ts; } } })?; if exchanges.is_empty() { return Ok((String::new(), last_ts)); } // Limit exchanges if count specified let exchanges_to_use = if let Some(count) = max_count { let start = exchanges.len().saturating_sub(count); &exchanges[start..] } else { &exchanges[..] }; // Format the context with project info let mut context = format!( "=== Context from {} ({}) ===\n\n", session.project_name, match session.provider { Provider::Claude => "Claude Code", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", } ); for (user_msg, assistant_msg, _ts) in exchanges_to_use { context.push_str("H: "); context.push_str(user_msg); context.push_str("\n\n"); context.push_str("A: "); context.push_str(assistant_msg); context.push_str("\n\n"); } context.push_str("=== End Context ===\n"); Ok((context, last_ts)) } fn find_codex_session_file(session_id: &str) -> Option<PathBuf> { let root = get_codex_sessions_dir(); if !root.exists() { return None; } let mut stack = vec![root]; while let Some(dir) = stack.pop() { let entries = match fs::read_dir(&dir) { Ok(v) => v, Err(_) => continue, }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.extension().map(|e| e == "jsonl").unwrap_or(false) { let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); if filename.contains(session_id) { return Some(path); } } } } None } fn find_cursor_session_file(session_id: &str) -> Option<PathBuf> { let root = get_cursor_projects_dir(); if !root.exists() { return None; } let entries = fs::read_dir(&root).ok()?; for entry in entries.flatten() { let project_dir = entry.path(); if !project_dir.is_dir() { continue; } for file_path in collect_cursor_project_session_files(&project_dir) { let filename = file_path.file_name().and_then(|s| s.to_str()).unwrap_or(""); if filename.contains(session_id) { return Some(file_path); } } } None } fn read_codex_cross_project_context( session: &CrossProjectSession, session_file: &PathBuf, since_ts: Option<&str>, max_count: Option<usize>, ) -> Result<(String, Option<String>)> { let (exchanges, last_ts) = read_codex_exchanges(session_file, since_ts, None)?; if exchanges.is_empty() { return Ok((String::new(), last_ts)); } let exchanges_to_use = if let Some(count) = max_count { let start = exchanges.len().saturating_sub(count); &exchanges[start..] } else { &exchanges[..] }; let mut context = format!( "=== Context from {} ({}) ===\n\n", session.project_name, match session.provider { Provider::Claude => "Claude Code", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", } ); for (user_msg, assistant_msg, _ts) in exchanges_to_use { context.push_str("H: "); context.push_str(user_msg); context.push_str("\n\n"); context.push_str("A: "); context.push_str(assistant_msg); context.push_str("\n\n"); } context.push_str("=== End Context ===\n"); Ok((context, last_ts)) } fn read_cursor_cross_project_context( session: &CrossProjectSession, session_file: &PathBuf, since_ts: Option<&str>, max_count: Option<usize>, ) -> Result<(String, Option<String>)> { let (exchanges, last_ts) = read_cursor_exchanges(session_file, since_ts, None)?; if exchanges.is_empty() { return Ok((String::new(), last_ts)); } let exchanges_to_use = if let Some(count) = max_count { let start = exchanges.len().saturating_sub(count); &exchanges[start..] } else { &exchanges[..] }; let mut context = format!( "=== Context from {} ({}) ===\n\n", session.project_name, match session.provider { Provider::Claude => "Claude Code", Provider::Codex => "Codex", Provider::Cursor => "Cursor", Provider::All => "AI", } ); for (user_msg, assistant_msg, _ts) in exchanges_to_use { context.push_str("H: "); context.push_str(user_msg); context.push_str("\n\n"); context.push_str("A: "); context.push_str(assistant_msg); context.push_str("\n\n"); } context.push_str("=== End Context ===\n"); Ok((context, last_ts)) } /// Get consumed checkpoints file path. fn get_consumed_checkpoints_path(project_path: &PathBuf) -> PathBuf { project_path .join(".ai") .join("internal") .join("consumed-checkpoints.json") } /// Load consumed checkpoints for a project. fn load_consumed_checkpoints(project_path: &PathBuf) -> Result<ConsumedCheckpoints> { let path = get_consumed_checkpoints_path(project_path); if !path.exists() { return Ok(ConsumedCheckpoints::default()); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; serde_json::from_str(&content).context("failed to parse consumed-checkpoints.json") } /// Save consumed checkpoint after copying context. fn save_consumed_checkpoint(session: &CrossProjectSession) -> Result<()> { let cwd = std::env::current_dir()?; let path = get_consumed_checkpoints_path(&cwd); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut checkpoints = load_consumed_checkpoints(&cwd).unwrap_or_default(); // Get the last timestamp from this session let last_ts = get_session_last_timestamp_for_path( &session.session_id, session.provider, &session.project_path, )? .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); let source_key = session.project_path.to_string_lossy().to_string(); checkpoints.consumed.insert( source_key, ConsumedEntry { last_timestamp: last_ts, consumed_at: chrono::Utc::now().to_rfc3339(), session_id: session.session_id.clone(), }, ); let content = serde_json::to_string_pretty(&checkpoints)?; fs::write(&path, content)?; Ok(()) } /// Get the last timestamp from a session file (for a specific project path). fn get_session_last_timestamp_for_path( session_id: &str, provider: Provider, project_path: &PathBuf, ) -> Result<Option<String>> { if provider == Provider::Codex { let session_file = find_codex_session_file(session_id); let Some(session_file) = session_file else { return Ok(None); }; return get_codex_last_timestamp(&session_file); } if provider == Provider::Cursor { let session_file = find_cursor_session_file(session_id); let Some(session_file) = session_file else { return Ok(None); }; return get_cursor_last_timestamp(&session_file); } let projects_dir = match provider { Provider::Claude | Provider::All => get_claude_projects_dir(), Provider::Codex => get_codex_projects_dir(), Provider::Cursor => get_cursor_projects_dir(), }; let project_folder = project_path.to_string_lossy().replace('/', "-"); let session_file = projects_dir .join(&project_folder) .join(format!("{}.jsonl", session_id)); if !session_file.exists() { return Ok(None); } let mut last_ts: Option<String> = None; for_each_nonempty_jsonl_line(&session_file, |line| { if let Ok(entry) = crate::json_parse::parse_json_line::<JsonlEntry>(line) { if let Some(ts) = entry.timestamp { last_ts = Some(ts); } } })?; Ok(last_ts) } #[cfg(test)] mod tests { use super::*; use std::fs; use std::process::Command; use tempfile::tempdir; fn init_temp_git_repo() -> tempfile::TempDir { let root = tempdir().expect("tempdir"); let status = Command::new("git") .args(["init"]) .current_dir(root.path()) .status() .expect("git init"); assert!(status.success()); root } #[test] fn decode_cursor_project_path_handles_hyphenated_components() { let root = tempfile::Builder::new() .prefix("cursorproject") .tempdir_in("/tmp") .expect("tempdir"); let repo_path = root .path() .join("review") .join("nikiv-designer-dev-deploy") .join("ide") .join("designer"); fs::create_dir_all(&repo_path).expect("create repo path"); let project_key = format!( "tmp-{}-review-nikiv-designer-dev-deploy-ide-designer", root.path() .file_name() .and_then(|name| name.to_str()) .expect("tempdir name") ); let decoded = decode_cursor_project_path(&project_key).expect("decoded path"); assert_eq!(decoded, repo_path); } #[test] fn parse_cursor_session_file_extracts_messages() { let root = tempdir().expect("tempdir"); let session_file = root.path().join("cursor-session.jsonl"); fs::write( &session_file, concat!( "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello cursor\"}]}}\n", "{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"world\"}]}}\n" ), ) .expect("write session file"); let session = parse_cursor_session_file(&session_file, "cursor-session").expect("parsed session"); assert_eq!(session.session_id, "cursor-session"); assert_eq!(session.provider, Provider::Cursor); assert_eq!(session.first_message.as_deref(), Some("hello cursor")); assert_eq!(session.last_message.as_deref(), Some("world")); assert!(session.timestamp.is_some()); assert_eq!(session.last_message_at, session.timestamp); } #[test] fn normalize_session_message_strips_setup_scaffolding() { let workflow_text = concat!( "ai sidebar improvements\n\n", "Workflow context:\n", "- Repo: ~/code/example-project\n", "- Review branch: review/example-feature\n", "\nStart by checking:\n1. flow status\n" ); assert_eq!( normalize_session_message("user", workflow_text).as_deref(), Some("ai sidebar improvements") ); let agents_text = concat!( "# AGENTS.md instructions for /tmp/repo\n\n", "<INSTRUCTIONS>\n", "Do important things.\n", "</INSTRUCTIONS>" ); assert_eq!(normalize_session_message("user", agents_text), None); let assistant_setup = "Using `example-dispatch`, then `example-workflow` because this is a stacked review workspace."; assert_eq!( normalize_session_message("assistant", assistant_setup), None ); } #[test] fn normalize_codex_resolve_args_accepts_trailing_json_flag() { let (query, json_output) = normalize_codex_resolve_args( vec![ "https://developers.cloudflare.com/changelog/post/2026-03-10-br-crawl-endpoint/" .to_string(), "--json".to_string(), ], false, ); assert!(json_output); assert_eq!( query, vec![ "https://developers.cloudflare.com/changelog/post/2026-03-10-br-crawl-endpoint/" .to_string() ] ); } #[test] fn select_codex_state_db_path_prefers_highest_version() { let root = tempdir().expect("tempdir"); fs::write(root.path().join("state_3.sqlite"), "").expect("write state_3"); fs::write(root.path().join("state_5.sqlite"), "").expect("write state_5"); fs::write(root.path().join("state_4.sqlite"), "").expect("write state_4"); let selected = select_codex_state_db_path(root.path()).expect("select state db"); assert_eq!(selected, root.path().join("state_5.sqlite")); } #[test] fn read_codex_thread_schema_detects_optional_columns() { let conn = Connection::open_in_memory().expect("open in-memory sqlite"); conn.execute_batch( r#" create table threads ( id text primary key, updated_at integer not null, cwd text not null, title text, first_user_message text, git_branch text ); "#, ) .expect("create threads table"); let initial = read_codex_thread_schema(&conn).expect("read initial schema"); assert_eq!( initial, CodexThreadSchema { has_model: false, has_reasoning_effort: false, } ); conn.execute_batch( r#" alter table threads add column model text; alter table threads add column reasoning_effort text; "#, ) .expect("alter threads table"); let updated = read_codex_thread_schema(&conn).expect("read updated schema"); assert_eq!( updated, CodexThreadSchema { has_model: true, has_reasoning_effort: true, } ); } #[test] fn read_codex_first_user_message_since_prefers_first_post_launch_turn() { let root = tempdir().expect("tempdir"); let session_file = root.path().join("codex.jsonl"); fs::write( &session_file, concat!( "{\"type\":\"response_item\",\"timestamp\":\"2026-03-16T10:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"old prompt\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-16T10:00:01Z\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"old answer\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-16T10:05:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"new prompt after launch\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-16T10:05:02Z\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"new answer\"}]}}\n" ), ) .expect("write session file"); let since_unix = parse_rfc3339_to_unix("2026-03-16T10:05:00Z").expect("parse timestamp"); let first = read_codex_first_user_message_since(&session_file, since_unix) .expect("read") .expect("first post-launch prompt"); assert_eq!(first.0, "new prompt after launch"); assert_eq!(first.1, since_unix); } #[test] fn read_codex_first_user_message_since_skips_contextual_scaffolding() { let root = tempdir().expect("tempdir"); let session_file = root.path().join("codex.jsonl"); fs::write( &session_file, concat!( "{\"type\":\"response_item\",\"timestamp\":\"2026-03-16T10:05:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"# AGENTS.md instructions for /tmp\\n\\n<INSTRUCTIONS>\\nbody\\n</INSTRUCTIONS>\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-16T10:05:01Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"<environment_context>\\n<cwd>/tmp</cwd>\\n</environment_context>\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-16T10:05:02Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"write plan for rollout\"}]}}\n" ), ) .expect("write session file"); let since_unix = parse_rfc3339_to_unix("2026-03-16T10:05:00Z").expect("parse timestamp"); let first = read_codex_first_user_message_since(&session_file, since_unix) .expect("read") .expect("first real prompt"); assert_eq!(first.0, "write plan for rollout"); assert_eq!(first.1, since_unix + 2); } #[test] fn append_history_message_skips_consecutive_duplicates() { let mut history = String::new(); let mut last_entry = None; append_history_message(&mut history, &mut last_entry, "user", "same"); append_history_message(&mut history, &mut last_entry, "user", "same"); append_history_message(&mut history, &mut last_entry, "assistant", "reply"); append_history_message(&mut history, &mut last_entry, "assistant", "reply"); assert_eq!(history, "Human: same\n\nAssistant: reply\n\n"); } #[test] fn codex_find_search_terms_keep_phrase_and_meaningful_tokens() { assert_eq!( codex_find_search_terms("make plan to get designer"), vec![ "make plan to get designer".to_string(), "make".to_string(), "plan".to_string(), "get".to_string(), "designer".to_string(), ] ); } #[test] fn rank_recover_rows_prefers_matching_session_id_prefix() { let mut rows = vec![ CodexRecoverRow { id: "019caaaa-0000-7000-8000-aaaaaaaaaaaa".to_string(), updated_at: 10, cwd: "/tmp/repo".to_string(), title: Some("one remaining unrelated issue".to_string()), first_user_message: Some("npm run lint still fails".to_string()), git_branch: Some("main".to_string()), model: None, reasoning_effort: None, }, CodexRecoverRow { id: "019cdcff-0b3a-7a80-b22b-5ac4ff076eff".to_string(), updated_at: 5, cwd: "/tmp/other".to_string(), title: Some("something else".to_string()), first_user_message: Some("different prompt".to_string()), git_branch: Some("feature".to_string()), model: None, reasoning_effort: None, }, ]; rank_recover_rows(&mut rows, Some("019cdcff")); assert_eq!(rows[0].id, "019cdcff-0b3a-7a80-b22b-5ac4ff076eff"); } #[test] fn extract_codex_session_hint_prefers_uuid_like_token() { assert_eq!( extract_codex_session_hint( "see 019cdcff-0b3a-7a80-b22b-5ac4ff076eff for work done on that" ), Some("019cdcff-0b3a-7a80-b22b-5ac4ff076eff".to_string()) ); } #[test] fn extract_codex_session_hint_ignores_git_sha_like_token() { assert_eq!( extract_codex_session_hint("see 3a4c62bfd29335a0170397b028a440c49858f1f5 for that"), None ); } #[test] fn extract_codex_session_reference_request_parses_count_and_followup() { let request = extract_codex_session_reference_request( "see 019ce6ce-c77a-7d52-838e-c01f8820f6b8 last 20 messages, research react hot reload", "see 019ce6ce-c77a-7d52-838e-c01f8820f6b8 last 20 messages, research react hot reload", ) .expect("expected session reference request"); assert_eq!( request.session_hints, vec!["019ce6ce-c77a-7d52-838e-c01f8820f6b8".to_string()] ); assert_eq!(request.count, 20); assert_eq!(request.user_request, "research react hot reload"); } #[test] fn extract_codex_session_reference_request_supports_two_session_hints() { let request = extract_codex_session_reference_request( "see 019cf695-d1d8-7e32-a572-f05e1d03d24f and 019cf983-79c3-7ad0-a870-05e308daa032 codex lets make dedicated plan for /tmp/review.md", "see 019cf695-d1d8-7e32-a572-f05e1d03d24f and 019cf983-79c3-7ad0-a870-05e308daa032 codex lets make dedicated plan for /tmp/review.md", ) .expect("expected session reference request"); assert_eq!( request.session_hints, vec![ "019cf695-d1d8-7e32-a572-f05e1d03d24f".to_string(), "019cf983-79c3-7ad0-a870-05e308daa032".to_string() ] ); assert_eq!( request.user_request, "lets make dedicated plan for /tmp/review.md" ); } #[test] fn extract_codex_session_reference_request_requires_followup_work() { assert!( extract_codex_session_reference_request( "see 019ce6ce-c77a-7d52-838e-c01f8820f6b8 last 20 messages", "see 019ce6ce-c77a-7d52-838e-c01f8820f6b8 last 20 messages", ) .is_none() ); } #[test] fn extract_codex_session_reference_request_does_not_steal_resume_queries() { assert!( extract_codex_session_reference_request( "resume 019ce6ce-c77a-7d52-838e-c01f8820f6b8", "resume 019ce6ce-c77a-7d52-838e-c01f8820f6b8", ) .is_none() ); } #[test] fn infer_recover_route_changes_directory_for_cross_repo_candidate() { let output = build_recover_output( Path::new("/tmp/current"), false, Some("019cdcff-0b3a-7a80-b22b-5ac4ff076eff".to_string()), vec![CodexRecoverRow { id: "019cdcff-0b3a-7a80-b22b-5ac4ff076eff".to_string(), updated_at: 5, cwd: "/tmp/other".to_string(), title: Some("something else".to_string()), first_user_message: Some("different prompt".to_string()), git_branch: Some("feature".to_string()), model: None, reasoning_effort: None, }], ); assert_eq!( output.recommended_route, "cd /tmp/other && f ai codex resume 019cdcff-0b3a-7a80-b22b-5ac4ff076eff" ); } #[test] fn session_lookup_detection_stays_conservative_for_general_session_work() { assert!(!looks_like_session_lookup_query( "improve session support in flow" )); assert!(!looks_like_session_lookup_query( "conversation summary pipeline cleanup" )); assert!(!looks_like_session_lookup_query( "write plan after reading https://github.com/openai/codex" )); } #[test] fn session_lookup_detection_accepts_explicit_control_prompts() { assert!(looks_like_session_lookup_query("resume session")); assert!(looks_like_session_lookup_query("show conversation")); assert!(looks_like_session_lookup_query("latest")); assert!(looks_like_session_lookup_query("after latest")); } #[test] fn wildcard_match_handles_linear_style_patterns() { assert!(wildcard_match( "https://linear.app/*/project/*", "https://linear.app/fl2024008/project/llm-proxy-v1-6cd0a041bd76/overview" )); assert!(wildcard_match( "https://linear.app/*/issue/*", "https://linear.app/fl2024008/issue/IDE-331/test-title" )); assert!(!wildcard_match( "https://linear.app/*/issue/*", "https://github.com/openai/codex" )); } fn sample_codex_doctor_snapshot() -> CodexDoctorSnapshot { CodexDoctorSnapshot { target: "/tmp/repo".to_string(), codex_bin: "codex-flow-wrapper".to_string(), codexd: "running".to_string(), codexd_socket: "/tmp/codexd.sock".to_string(), memory_state: "ready".to_string(), memory_root: "/tmp/jazz2/codex-memory".to_string(), memory_db_path: "/tmp/jazz2/codex-memory/memory.sqlite".to_string(), memory_events_indexed: 9, memory_facts_indexed: 12, runtime_transport: "enabled".to_string(), runtime_skills: "enabled".to_string(), auto_resolve_references: true, home_session_path: "/tmp/home".to_string(), prompt_context_budget_chars: 1200, max_resolved_references: 2, reference_resolvers: 0, query_cache: "enabled".to_string(), query_cache_entries_on_disk: 4, skill_eval_events_on_disk: 6, skill_eval_outcomes_on_disk: 3, skill_scorecard_samples: 6, skill_scorecard_entries: 2, skill_scorecard_top: Some("plan_write (0.91)".to_string()), external_skill_candidates: 1, runtime_state_files: 2, runtime_state_files_for_target: 1, skill_eval_schedule: "loaded".to_string(), learning_state: "grounded".to_string(), runtime_ready: true, schedule_ready: true, learning_ready: true, warnings: Vec::new(), } } #[test] fn codex_doctor_assert_autonomous_accepts_grounded_snapshot() { let snapshot = sample_codex_doctor_snapshot(); assert!(assert_codex_doctor(&snapshot, false, false, false, true).is_ok()); } #[test] fn codex_doctor_assert_learning_requires_grounded_outcomes() { let mut snapshot = sample_codex_doctor_snapshot(); snapshot.skill_eval_outcomes_on_disk = 0; snapshot.learning_ready = false; snapshot.learning_state = "affinity-only".to_string(); let err = assert_codex_doctor(&snapshot, false, false, true, false) .expect_err("learning assertion should fail without outcomes"); let message = format!("{err:#}"); assert!(message.contains("no grounded skill outcome events recorded yet")); } #[test] fn codex_eval_opportunities_flag_missing_runtime_and_daemon() { let mut snapshot = sample_codex_doctor_snapshot(); snapshot.runtime_transport = "disabled".to_string(); snapshot.runtime_skills = "disabled".to_string(); snapshot.runtime_ready = false; snapshot.codexd = "stopped".to_string(); snapshot.skill_eval_outcomes_on_disk = 0; snapshot.learning_ready = false; let opportunities = build_codex_eval_opportunities(&snapshot, 4, 0, &[], &[]); assert!(opportunities .iter() .any(|item| item.title.contains("Wrapper/runtime path"))); assert!(opportunities .iter() .any(|item| item.title.contains("codexd is not running"))); assert!(opportunities .iter() .any(|item| item.title.contains("No grounded outcome samples for this target yet"))); } #[test] fn codex_eval_summary_prefers_grounded_signal_when_ready() { let snapshot = sample_codex_doctor_snapshot(); let route = CodexEvalRouteSnapshot { route: "new-with-context".to_string(), count: 4, share: 0.5, avg_context_chars: 420.0, avg_reference_count: 1.0, runtime_activation_rate: 0.75, last_recorded_at_unix: 10, }; let skill = CodexEvalSkillSnapshot { name: "github".to_string(), score: 12.0, sample_size: 4, outcome_samples: 3, pass_rate: 1.0, normalized_gain: 0.4, avg_context_chars: 300.0, }; let summary = build_codex_eval_summary(&snapshot, 8, 3, Some(&route), Some(&skill)); assert!(summary.contains("grounded learning is active")); assert!(summary.contains("top route: new-with-context")); assert!(summary.contains("top skill: github")); } #[test] fn codex_eval_quality_marks_blocking_runtime_failures_erroneous() { let mut snapshot = sample_codex_doctor_snapshot(); snapshot.runtime_transport = "disabled".to_string(); snapshot.runtime_skills = "configured-but-inactive".to_string(); snapshot.runtime_ready = false; snapshot.memory_state = "unavailable".to_string(); let quality = build_codex_eval_quality(&snapshot, 5, 0); assert_eq!(quality.status, "erroneous"); assert!(!quality.grounded); assert!(quality .failure_modes .iter() .any(|mode| mode.contains("wrapper transport disabled"))); assert!(quality .failure_modes .iter() .any(|mode| mode.contains("runtime skills"))); assert!(quality .failure_modes .iter() .any(|mode| mode.contains("codex memory unavailable"))); } #[test] fn codex_eval_quality_stays_valid_while_warming_up() { let snapshot = sample_codex_doctor_snapshot(); let quality = build_codex_eval_quality(&snapshot, 3, 0); assert_eq!(quality.status, "valid"); assert!(!quality.grounded); assert!(quality.failure_modes.is_empty()); assert!(quality.summary.contains("warming up")); } #[test] fn parse_linear_url_reference_extracts_project_shape() { let reference = parse_linear_url_reference( "https://linear.app/fl2024008/project/llm-proxy-v1-6cd0a041bd76/overview", ) .expect("linear project url should parse"); assert_eq!(reference.workspace_slug, "fl2024008"); assert_eq!(reference.resource_kind, LinearUrlKind::Project); assert_eq!(reference.resource_value, "llm-proxy-v1-6cd0a041bd76"); assert_eq!(reference.view.as_deref(), Some("overview")); assert_eq!(reference.title_hint, "llm proxy v1"); } #[test] fn github_pr_url_detection_is_specific() { assert!(looks_like_github_pr_url( "https://github.com/fl2024008/prometheus/pull/2922" )); assert!(!looks_like_github_pr_url( "https://github.com/fl2024008/prometheus/issues/2922" )); } #[test] fn pr_feedback_query_detection_matches_check_and_comments() { assert!(looks_like_pr_feedback_query( "check https://github.com/fl2024008/prometheus/pull/2922" )); assert!(looks_like_pr_feedback_query( "see https://github.com/fl2024008/prometheus/pull/2922 for comments" )); assert!(!looks_like_pr_feedback_query( "open https://github.com/fl2024008/prometheus/pull/2922 in browser" )); } #[test] fn commit_workflow_query_detection_matches_high_confidence_phrases() { assert!(looks_like_commit_workflow_query("commit")); assert!(looks_like_commit_workflow_query("commit and push")); assert!(looks_like_commit_workflow_query("review and commit")); } #[test] fn commit_workflow_query_detection_stays_conservative() { assert!(!looks_like_commit_workflow_query( "improve commit queue throughput" )); assert!(!looks_like_commit_workflow_query( "explain commit routing in flow" )); } #[test] fn prom_sync_workflow_query_detection_matches_high_confidence_phrases() { assert!(looks_like_prom_sync_workflow_query("sync branch")); assert!(looks_like_prom_sync_workflow_query("sync this branch")); assert!(looks_like_prom_sync_workflow_query("sync with origin/main")); } #[test] fn prom_sync_workflow_query_detection_stays_conservative() { assert!(!looks_like_prom_sync_workflow_query( "explain sync branch semantics" )); assert!(!looks_like_prom_sync_workflow_query( "sync branch protection settings" )); } #[test] fn build_codex_open_plan_routes_plain_commit_into_commit_workflow() { let root = init_temp_git_repo(); fs::write(root.path().join("README.md"), "hello\n").expect("write readme"); let plan = build_codex_open_plan( Some(root.path().display().to_string()), vec!["commit".to_string()], false, ) .expect("commit plan"); assert_eq!(plan.route, "commit-workflow-new"); assert_eq!(plan.action, "new"); assert_eq!(plan.references[0].name, "commit-workflow"); assert_eq!( plan.references[0].command.as_deref(), Some("f commit --slow --context") ); let prompt = plan.prompt.expect("prompt"); assert!(prompt.contains("Commit workflow contract:")); assert!(prompt.contains("deep-review-then-commit")); } #[test] fn build_codex_open_plan_routes_prom_sync_branch_into_sync_workflow() { let temp = tempdir().expect("tempdir"); let root = temp.path().join("code").join("prom").join("review-workspace"); fs::create_dir_all(&root).expect("create root"); Command::new("git") .arg("init") .arg("-q") .current_dir(&root) .status() .expect("git init"); let plan = build_codex_open_plan( Some(root.display().to_string()), vec!["sync branch".to_string()], false, ) .expect("sync plan"); assert_eq!(plan.route, "sync-workflow-new"); assert_eq!(plan.action, "new"); assert_eq!(plan.references[0].name, "sync-workflow"); assert_eq!(plan.references[0].command.as_deref(), Some("forge sync")); let prompt = plan.prompt.expect("prompt"); assert!(prompt.contains("Sync workflow contract:")); assert!(prompt.contains("guarded repo sync workflow")); } #[test] fn parse_pr_feedback_cursor_handoff_extracts_paths() { let handoff = parse_pr_feedback_cursor_handoff( "[pr-feedback]\n\ Workspace: /tmp/repo\n\ PR feedback: owner/repo#1\n\ Review plan: /tmp/plan.md\n\ Review rules: /tmp/review-rules.md\n\ Kit system prompt: /tmp/kit.md\n", ) .expect("handoff"); assert_eq!(handoff.workspace_path, PathBuf::from("/tmp/repo")); assert_eq!(handoff.review_plan_path, PathBuf::from("/tmp/plan.md")); assert_eq!( handoff.review_rules_path, Some(PathBuf::from("/tmp/review-rules.md")) ); assert_eq!(handoff.kit_system_path, PathBuf::from("/tmp/kit.md")); } #[test] fn build_codex_prompt_keeps_plain_query_plain() { assert_eq!( build_codex_prompt("improve codex open perf", &[], 2, 1200).as_deref(), Some("improve codex open perf") ); } #[test] fn build_codex_prompt_avoids_duplicate_reference_header() { let references = vec![CodexResolvedReference { name: "pr-feedback".to_string(), source: "builtin".to_string(), matched: "https://github.com/example/repo/pull/1".to_string(), command: None, output: "[pr-feedback]\nReview plan: /tmp/plan.md".to_string(), }]; let prompt = build_codex_prompt("check pr", &references, 2, 600).expect("prompt"); assert_eq!(prompt.matches("[pr-feedback]").count(), 1); } #[test] fn parse_reference_fields_extracts_pr_feedback_artifacts() { let fields = parse_reference_fields( "[pr-feedback]\n\ Workspace: /tmp/repo\n\ PR feedback: owner/repo#1\n\ Trace ID: trace-1\n\ URL: https://github.com/owner/repo/pull/1\n\ Snapshot markdown: /tmp/repo/.ai/reviews/pr-feedback-1.md\n\ Snapshot json: /tmp/repo/.ai/reviews/pr-feedback-1.json\n\ Review plan: /tmp/plan.md\n\ Review rules: /tmp/review-rules.md\n\ Kit system prompt: /tmp/kit.md\n\ Cursor reopen: f pr feedback https://github.com/owner/repo/pull/1 --compact --cursor\n\ Summary:\n\ - Actionable items: 6\n", ); assert_eq!(fields.get("workspace").map(String::as_str), Some("/tmp/repo")); assert_eq!( fields.get("snapshot markdown").map(String::as_str), Some("/tmp/repo/.ai/reviews/pr-feedback-1.md") ); assert_eq!( fields.get("review plan").map(String::as_str), Some("/tmp/plan.md") ); assert_eq!(fields.get("trace id").map(String::as_str), Some("trace-1")); assert_eq!( fields.get("cursor reopen").map(String::as_str), Some("f pr feedback https://github.com/owner/repo/pull/1 --compact --cursor") ); } #[test] fn derive_codex_open_plan_trace_assigns_plain_routes() { let plan = CodexOpenPlan { action: "new".to_string(), route: "new-plain".to_string(), reason: "start a new session from the current query".to_string(), target_path: "/tmp/repo".to_string(), launch_path: "/tmp/repo".to_string(), query: Some("summarize this repo".to_string()), session_id: None, prompt: Some("summarize this repo".to_string()), references: Vec::new(), runtime_state_path: None, runtime_skills: Vec::new(), prompt_context_budget_chars: 1200, max_resolved_references: 3, prompt_chars: 19, injected_context_chars: 0, trace: None, }; let trace = derive_codex_open_plan_trace(&plan).expect("trace"); assert_eq!(trace.workflow_kind, "new_plain"); assert_eq!(trace.service_name, FLOW_CODEX_TRACE_SERVICE_NAME); assert_eq!(trace.trace_id.len(), 32); assert_eq!(trace.span_id.len(), 16); } #[test] fn build_pr_feedback_workflow_explanation_surfaces_packet_and_command() { let plan = CodexOpenPlan { action: "new".to_string(), route: "new-with-context".to_string(), reason: "builtin pr feedback route".to_string(), target_path: "/tmp/repo".to_string(), launch_path: "/tmp/repo".to_string(), query: Some("check https://github.com/owner/repo/pull/1".to_string()), session_id: None, prompt: Some("prompt".to_string()), references: vec![CodexResolvedReference { name: "pr-feedback".to_string(), source: "builtin".to_string(), matched: "https://github.com/owner/repo/pull/1".to_string(), command: Some("f pr feedback https://github.com/owner/repo/pull/1".to_string()), output: "[pr-feedback]\n\ Workspace: /tmp/repo\n\ PR feedback: owner/repo#1\n\ Trace ID: trace-1\n\ URL: https://github.com/owner/repo/pull/1\n\ Snapshot markdown: /tmp/repo/.ai/reviews/pr-feedback-1.md\n\ Snapshot json: /tmp/repo/.ai/reviews/pr-feedback-1.json\n\ Review plan: /tmp/plan.md\n\ Review rules: /tmp/review-rules.md\n\ Kit system prompt: /tmp/kit.md\n\ Cursor reopen: f pr feedback https://github.com/owner/repo/pull/1 --compact --cursor\n" .to_string(), }], runtime_state_path: None, runtime_skills: vec![], prompt_context_budget_chars: 2400, max_resolved_references: 2, prompt_chars: 100, injected_context_chars: 80, trace: Some(CodexResolveWorkflowTrace { trace_id: "trace-1".to_string(), span_id: "span-1".to_string(), parent_span_id: None, workflow_kind: "pr_feedback".to_string(), service_name: FLOW_CODEX_TRACE_SERVICE_NAME.to_string(), }), }; let runtime_skills = vec![CodexResolveRuntimeSkillSnapshot { name: "flow-runtime-ext-dimillian-skills-github".to_string(), kind: "external".to_string(), path: "/tmp/github".to_string(), trigger: "github".to_string(), source: Some("dimillian".to_string()), original_name: Some("github".to_string()), estimated_chars: Some(1200), match_reason: Some("matched skill name phrase `github`".to_string()), }]; let workflow = build_codex_resolve_workflow_explanation(&plan, &runtime_skills) .expect("workflow explanation"); assert_eq!(workflow.id, "pr-feedback"); assert_eq!(workflow.packet.kind, "pr_feedback"); assert!(workflow .packet .expansion_rules .iter() .any(|rule| rule.contains("Read the compact packet first"))); assert!(workflow .packet .validation_plan .iter() .any(|item| item.label == "Per-item product validation")); assert_eq!(workflow.commands.first().map(|c| c.command.as_str()), Some("f pr feedback https://github.com/owner/repo/pull/1")); assert!(workflow .artifacts .iter() .any(|artifact| artifact.label == "Review plan" && artifact.value == "/tmp/plan.md")); assert!(workflow .artifacts .iter() .any(|artifact| artifact.label == "Trace ID" && artifact.value == "trace-1")); assert_eq!( workflow .packet .trace .as_ref() .map(|trace| trace.trace_id.as_str()), Some("trace-1") ); assert!(workflow .notes .iter() .any(|note| note.contains("Runtime skill: github"))); } #[test] fn build_codex_prompt_respects_shared_context_budget() { let references = vec![ CodexResolvedReference { name: "docs".to_string(), source: "resolver".to_string(), matched: "one".to_string(), command: None, output: "A".repeat(500), }, CodexResolvedReference { name: "issue".to_string(), source: "resolver".to_string(), matched: "two".to_string(), command: None, output: "B".repeat(500), }, ]; let prompt = build_codex_prompt("summarize", &references, 2, 260).expect("prompt should exist"); assert!(prompt.chars().count() <= 260); assert!(prompt.contains("User request:")); } #[test] fn read_codex_session_completion_snapshot_tracks_latest_completed_turn() { let root = tempdir().expect("tempdir"); let session_file = root.path().join("codex.jsonl"); fs::write( &session_file, concat!( "{\"type\":\"response_item\",\"timestamp\":\"2026-03-17T10:00:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"first prompt\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-17T10:00:01Z\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"first answer\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-17T10:01:00Z\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"second prompt\"}]}}\n", "{\"type\":\"response_item\",\"timestamp\":\"2026-03-17T10:01:03Z\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"second answer\"}]}}\n" ), ) .expect("write session file"); let snapshot = read_codex_session_completion_snapshot(&session_file) .expect("snapshot") .expect("completion snapshot"); assert_eq!(snapshot.last_role.as_deref(), Some("assistant")); assert_eq!(snapshot.last_user_message.as_deref(), Some("second prompt")); assert_eq!( snapshot.last_assistant_message.as_deref(), Some("second answer") ); assert_eq!( snapshot.last_assistant_at_unix, parse_rfc3339_to_unix("2026-03-17T10:01:03Z") ); } #[test] fn select_codex_session_completion_summary_prefers_last_user_message() { let row = CodexRecoverRow { id: "019ce791-7e05-7e51-b2b7-610dc7172e5c".to_string(), updated_at: 0, cwd: "/tmp/repo".to_string(), title: Some("fallback title".to_string()), first_user_message: Some("older intent".to_string()), git_branch: None, model: None, reasoning_effort: None, }; let snapshot = CodexSessionCompletionSnapshot { last_role: Some("assistant".to_string()), last_user_message: Some("implement codex session logging".to_string()), last_user_at_unix: Some(1), last_assistant_message: Some("done".to_string()), last_assistant_at_unix: Some(2), file_modified_unix: 2, }; let summary = select_codex_session_completion_summary(&row, &snapshot); assert_eq!(summary, "implement codex session logging"); } #[test] fn parse_apply_patch_changes_extracts_absolute_paths() { let changes = parse_apply_patch_changes( concat!( "*** Begin Patch\n", "*** Update File: /tmp/config/fish/fn.fish\n", "@@\n", "+function j\n", "*** Add File: relative/new-file.rs\n", "+fn main() {}\n", "*** End Patch\n", ), "/tmp/code/flow", ); assert_eq!(changes.len(), 2); assert_eq!(changes[0].path, "/tmp/config/fish/fn.fish"); assert_eq!(changes[0].action, "update"); assert!(changes[0].patch.contains("function j")); assert_eq!(changes[1].path, "/tmp/code/flow/relative/new-file.rs"); assert_eq!(changes[1].action, "add"); } #[test] fn summarize_fish_fn_change_detects_shortcut_remap() { let summary = summarize_fish_fn_change( "j runs f codex open --path (pwd -P) --exact-cwd. \ k uses f codex connect --path (pwd -P) --exact-cwd. \ l is now Kit for ~/repos/mark3labs/kit. \ L now delegates to j. old k moved to cl. old l moved to cf. old L moved to cF.", ) .expect("summary"); assert!(summary.contains("j->codex.open")); assert!(summary.contains("k->codex.connect")); assert!(summary.contains("l->kit")); assert!(summary.contains("L->j")); assert!(summary.contains("cl/cf/cF")); } #[test] fn build_codex_session_changed_events_uses_fish_summary_fallback() { let root = tempdir().expect("tempdir"); let session_file = root.path().join("codex.jsonl"); fs::write(&session_file, "").expect("write empty session file"); let row = CodexRecoverRow { id: "019ce791-7e05-7e51-b2b7-610dc7172e5c".to_string(), updated_at: 0, cwd: "/tmp/code/flow".to_string(), title: None, first_user_message: None, git_branch: None, model: None, reasoning_effort: None, }; let snapshot = CodexSessionCompletionSnapshot { last_role: Some("assistant".to_string()), last_user_message: Some( "The remap is in fn.fish. j runs f codex open --path (pwd -P) --exact-cwd. \ k uses f codex connect --path (pwd -P) --exact-cwd. \ l is now Kit. L now delegates to j. old k moved to cl. old l moved to cf. old L moved to cF." .to_string(), ), last_user_at_unix: Some(1), last_assistant_message: Some("logged".to_string()), last_assistant_at_unix: Some(2), file_modified_unix: 2, }; let events = build_codex_session_changed_events(&row, &snapshot, &session_file).expect("events"); assert_eq!(events.len(), 1); assert_eq!(events[0].kind, "fish.fn"); assert!(events[0].summary.contains("j->codex.open")); assert!(events[0].summary.contains("k->codex.connect")); } #[test] fn format_session_ref_respects_provider_prefix_flag() { let session = AiSession { session_id: "019ce791-7e05-7e51-b2b7-610dc7172e5c".to_string(), provider: Provider::Codex, timestamp: None, last_message_at: None, last_message: None, first_message: None, error_summary: None, }; assert_eq!( format_session_ref(&session, false), "019ce791-7e05-7e51-b2b7-610dc7172e5c" ); assert_eq!( format_session_ref(&session, true), "codex:019ce791-7e05-7e51-b2b7-610dc7172e5c" ); } #[test] fn ai_session_from_codex_recover_row_prefers_title_for_preview() { let session = ai_session_from_codex_recover_row(CodexRecoverRow { id: "019ce791-7e05-7e51-b2b7-610dc7172e5c".to_string(), updated_at: 1_773_776_290, cwd: "/tmp/repo".to_string(), title: Some("review github integration".to_string()), first_user_message: Some("older prompt".to_string()), git_branch: None, model: None, reasoning_effort: None, }); assert_eq!( session.last_message.as_deref(), Some("review github integration") ); assert_eq!(session.first_message.as_deref(), Some("older prompt")); assert_eq!(session.provider, Provider::Codex); assert!(session.last_message_at.is_some()); } #[test] fn ai_session_from_codex_recover_row_falls_back_to_first_user_message() { let session = ai_session_from_codex_recover_row(CodexRecoverRow { id: "019ce791-7e05-7e51-b2b7-610dc7172e5c".to_string(), updated_at: 1_773_776_290, cwd: "/tmp/repo".to_string(), title: None, first_user_message: Some("inspect the current diff".to_string()), git_branch: None, model: None, reasoning_effort: None, }); assert_eq!( session.last_message.as_deref(), Some("inspect the current diff") ); assert_eq!( session.first_message.as_deref(), Some("inspect the current diff") ); } } ================================================ FILE: src/ai_context.rs ================================================ //! AI context loading from `.ai/context/` directories. //! //! This module provides functionality to load contextual AI instructions //! from `.ai/context/` directories in the project root. //! //! ## Directory Structure //! //! ``` //! .ai/ //! context/ //! commands/ # Command-specific context //! sync.md # Context for `f sync` //! deploy.md # Context for `f deploy` //! ... //! tasks/ # Task-specific context (by task name) //! build.md //! test.md //! ... //! project.md # General project context //! ``` //! //! ## Usage //! //! Context files are markdown with rules, patterns, and instructions //! that get included in AI prompts for better conflict resolution, //! code generation, and task execution. use std::fs; use std::path::{Path, PathBuf}; /// Find the project root by looking for common markers. pub fn find_project_root() -> Option<PathBuf> { let cwd = std::env::current_dir().ok()?; let mut current = cwd.as_path(); loop { // Check for .ai/context directory if current.join(".ai/context").exists() { return Some(current.to_path_buf()); } // Check for other common project markers if current.join(".git").exists() || current.join("flow.toml").exists() || current.join("Cargo.toml").exists() || current.join("package.json").exists() { return Some(current.to_path_buf()); } current = current.parent()?; } } /// Load context for a specific flow command (e.g., "sync", "deploy"). pub fn load_command_context(command: &str) -> Option<String> { let root = find_project_root()?; let context_path = root .join(".ai/context/commands") .join(format!("{}.md", command)); load_context_file(&context_path) } /// Load context for a specific task name. pub fn load_task_context(task_name: &str) -> Option<String> { let root = find_project_root()?; let context_path = root .join(".ai/context/tasks") .join(format!("{}.md", task_name)); load_context_file(&context_path) } /// Load the general project context. pub fn load_project_context() -> Option<String> { let root = find_project_root()?; let context_path = root.join(".ai/context/project.md"); load_context_file(&context_path) } /// Load all relevant context for a command, combining project + command context. pub fn load_full_command_context(command: &str) -> String { let mut context = String::new(); if let Some(project_ctx) = load_project_context() { context.push_str("## Project Context\n\n"); context.push_str(&project_ctx); context.push_str("\n\n"); } if let Some(cmd_ctx) = load_command_context(command) { context.push_str(&format!("## {} Command Context\n\n", command)); context.push_str(&cmd_ctx); context.push_str("\n\n"); } context } /// Load a context file if it exists. fn load_context_file(path: &Path) -> Option<String> { if path.exists() { fs::read_to_string(path).ok() } else { None } } /// Check if any AI context exists for the current project. pub fn has_ai_context() -> bool { find_project_root() .map(|root| root.join(".ai/context").exists()) .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; #[test] fn test_find_project_root_returns_some_in_git_repo() { // This test assumes we're running from within a git repo let root = find_project_root(); assert!(root.is_some() || std::env::var("CI").is_ok()); } } ================================================ FILE: src/ai_everruns.rs ================================================ use std::collections::HashSet; use std::io::{self, BufRead, BufReader, IsTerminal, Read}; use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use reqwest::blocking::{Client, RequestBuilder}; use seq_everruns_bridge::{ ToolCall as BridgeToolCall, build_request as bridge_build_request, client_side_tool_definitions as bridge_tool_definitions, maple::{MapleSpan, MapleTraceExporter}, parse_tool_call_requested, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::cli::AiEverrunsOpts; use crate::config::{self, EverrunsConfig}; use crate::rl_signals; use crate::seq_client::{RpcRequest, SeqClient}; const DEFAULT_EVERRUNS_BASE_URL: &str = "http://127.0.0.1:9300/api"; const DEFAULT_EVERRUNS_API_KEY_ENV: &str = "EVERRUNS_API_KEY"; const DEFAULT_SEQ_SOCKET: &str = "/tmp/seqd.sock"; pub fn run(opts: AiEverrunsOpts) -> Result<()> { let prompt = resolve_prompt(&opts)?; let resolved = ResolvedSettings::from_opts(&opts)?; let api = EverrunsApi::new(resolved.base_url.clone(), resolved.api_key.clone())?; let session_id = resolve_session_id(&api, &resolved)?; let seq_bridge = SeqBridge::connect(&resolved)?; eprintln!("everruns session: {}", session_id); let message_id = api.post_message(&session_id, &prompt)?; eprintln!("message_id: {}", message_id); rl_signals::emit(json!({ "event_type": "everruns.run_started", "runtime": "everruns", "session_id": session_id, "input_message_id": message_id, "prompt_chars": prompt.chars().count(), "prompt_text": text_signal_payload(&prompt), "poll_interval_ms": resolved.poll_ms, "timeout_secs": resolved.wait_timeout_secs, "seq_socket": resolved.seq_socket.display().to_string(), "no_seq_tools": resolved.no_seq_tools, })); let run_started = Instant::now(); let result = wait_for_completion( &api, &seq_bridge, &session_id, &message_id, &prompt, resolved.poll_ms, resolved.wait_timeout_secs, ); let runtime_ms = run_started.elapsed().as_millis() as u64; match &result { Ok(_) => rl_signals::emit(json!({ "event_type": "everruns.run_completed", "runtime": "everruns", "session_id": session_id, "input_message_id": message_id, "ok": true, "runtime_ms": runtime_ms, })), Err(err) => rl_signals::emit(json!({ "event_type": "everruns.run_failed", "runtime": "everruns", "session_id": session_id, "input_message_id": message_id, "ok": false, "runtime_ms": runtime_ms, "error": err.to_string(), "error_class": classify_error_text(&err.to_string()), })), } result } fn resolve_prompt(opts: &AiEverrunsOpts) -> Result<String> { if !opts.prompt.is_empty() { let joined = opts.prompt.join(" ").trim().to_string(); if joined.is_empty() { bail!("prompt is empty"); }; return Ok(joined); } if io::stdin().is_terminal() { bail!("missing prompt. Usage: f ai everruns \"your prompt\""); } let mut buf = String::new(); io::stdin() .read_to_string(&mut buf) .context("failed to read prompt from stdin")?; let prompt = buf.trim().to_string(); if prompt.is_empty() { bail!("prompt from stdin is empty"); } Ok(prompt) } fn wait_for_completion( api: &EverrunsApi, seq_bridge: &SeqBridge, session_id: &str, input_message_id: &str, prompt: &str, poll_ms: u64, wait_timeout_secs: u64, ) -> Result<()> { match wait_for_completion_sse( api, seq_bridge, session_id, input_message_id, prompt, poll_ms, wait_timeout_secs, ) { Ok(()) => Ok(()), Err(err) => { if is_sse_unavailable_error(&err) { eprintln!( "note: Everruns SSE endpoint unavailable, falling back to /events polling" ); rl_signals::emit(json!({ "event_type": "everruns.transport_fallback", "runtime": "everruns", "session_id": session_id, "input_message_id": input_message_id, "from": "sse", "to": "poll", "reason": err.to_string(), })); return wait_for_completion_poll( api, seq_bridge, session_id, input_message_id, prompt, poll_ms, wait_timeout_secs, ); } Err(err) } } } fn wait_for_completion_sse( api: &EverrunsApi, seq_bridge: &SeqBridge, session_id: &str, input_message_id: &str, prompt: &str, poll_ms: u64, wait_timeout_secs: u64, ) -> Result<()> { let started = Instant::now(); let timeout = Duration::from_secs(wait_timeout_secs.max(1)); let mut since_id: Option<String> = None; let mut handled_tool_calls = HashSet::new(); let mut saw_stream = false; loop { if started.elapsed() > timeout { bail!( "timed out waiting for Everruns output after {}s", wait_timeout_secs ); } let remaining = timeout.saturating_sub(started.elapsed()); let stream_timeout = remaining .min(Duration::from_secs(70)) .max(Duration::from_secs(1)); let batch = match api.read_sse_batch(session_id, since_id.as_deref(), stream_timeout) { Ok(batch) => { saw_stream = true; batch } Err(err) => { if !saw_stream && is_sse_unavailable_error(&err) { return Err(err); } thread::sleep(Duration::from_millis(poll_ms.max(25))); continue; } }; let mut did_work = false; for event in batch.events { let event_started = Instant::now(); let event_start_ns = unix_time_nanos_now(); since_id = Some(event.id.clone()); if let Some(ref event_input_id) = event.context.input_message_id && event_input_id != input_message_id { continue; } match event.event_type.as_str() { "tool.call_requested" => { let requested_calls = parse_tool_call_requested(&event.data).with_context(|| { format!( "failed to parse tool.call_requested payload for event {}", event.id ) })?; let requested_count = requested_calls.len(); let mut tool_results = Vec::new(); for call in requested_calls { if !handled_tool_calls.insert(call.id.clone()) { continue; } tool_results .push(seq_bridge.execute_tool_call(session_id, &event.id, call)); } let unique_count = tool_results.len(); let duplicate_count = requested_count.saturating_sub(unique_count); if !tool_results.is_empty() { api.submit_tool_results(session_id, tool_results)?; did_work = true; } seq_bridge.emit_runtime_event( session_id, &event.id, "tool_call_requested", true, None, event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![ ( "tool_calls.requested".to_string(), requested_count.to_string(), ), ("tool_calls.unique".to_string(), unique_count.to_string()), ( "tool_calls.duplicates_filtered".to_string(), duplicate_count.to_string(), ), ], ); } "output.message.completed" => { let output_text = extract_output_text(&event.data); if let Some(text) = output_text.as_ref() { println!("{}", text); } else { println!("{}", serde_json::to_string_pretty(&event.data)?); } let output_chars = output_text.as_ref().map(|t| t.chars().count()).unwrap_or(0); seq_bridge.emit_runtime_event( session_id, &event.id, "output_message_completed", true, None, event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![("output_chars".to_string(), output_chars.to_string())], ); emit_qa_pair_signal( session_id, input_message_id, &event.id, prompt, output_text.as_deref().unwrap_or(""), ); return Ok(()); } "turn.failed" => { let error = event .data .get("error") .and_then(Value::as_str) .unwrap_or("unknown turn failure"); seq_bridge.emit_runtime_event( session_id, &event.id, "turn_failed", false, Some(error), event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![( "error_class".to_string(), classify_error_text(error).to_string(), )], ); bail!("everruns turn failed: {}", error); } _ => { seq_bridge.emit_runtime_event( session_id, &event.id, &format!("event_{}", event.event_type.replace(['.', '-', ' '], "_")), true, None, event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![], ); } } } if !did_work && !batch.saw_disconnect { thread::sleep(Duration::from_millis(poll_ms.max(25))); } } } fn wait_for_completion_poll( api: &EverrunsApi, seq_bridge: &SeqBridge, session_id: &str, input_message_id: &str, prompt: &str, poll_ms: u64, wait_timeout_secs: u64, ) -> Result<()> { let started = Instant::now(); let mut since_id: Option<String> = None; let mut handled_tool_calls = HashSet::new(); loop { if started.elapsed() > Duration::from_secs(wait_timeout_secs.max(1)) { bail!( "timed out waiting for Everruns output after {}s", wait_timeout_secs ); } let events = api.list_events(session_id, since_id.as_deref())?; if let Some(last) = events.last() { since_id = Some(last.id.clone()); } let mut did_work = false; for event in events { let event_started = Instant::now(); let event_start_ns = unix_time_nanos_now(); if let Some(ref event_input_id) = event.context.input_message_id && event_input_id != input_message_id { continue; } match event.event_type.as_str() { "tool.call_requested" => { let requested_calls = parse_tool_call_requested(&event.data).with_context(|| { format!( "failed to parse tool.call_requested payload for event {}", event.id ) })?; let requested_count = requested_calls.len(); let mut tool_results = Vec::new(); for call in requested_calls { if !handled_tool_calls.insert(call.id.clone()) { continue; } tool_results .push(seq_bridge.execute_tool_call(session_id, &event.id, call)); } let unique_count = tool_results.len(); let duplicate_count = requested_count.saturating_sub(unique_count); if !tool_results.is_empty() { api.submit_tool_results(session_id, tool_results)?; did_work = true; } seq_bridge.emit_runtime_event( session_id, &event.id, "tool_call_requested", true, None, event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![ ( "tool_calls.requested".to_string(), requested_count.to_string(), ), ("tool_calls.unique".to_string(), unique_count.to_string()), ( "tool_calls.duplicates_filtered".to_string(), duplicate_count.to_string(), ), ], ); } "output.message.completed" => { let output_text = extract_output_text(&event.data); if let Some(text) = output_text.as_ref() { println!("{}", text); } else { println!("{}", serde_json::to_string_pretty(&event.data)?); } let output_chars = output_text.as_ref().map(|t| t.chars().count()).unwrap_or(0); seq_bridge.emit_runtime_event( session_id, &event.id, "output_message_completed", true, None, event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![("output_chars".to_string(), output_chars.to_string())], ); emit_qa_pair_signal( session_id, input_message_id, &event.id, prompt, output_text.as_deref().unwrap_or(""), ); return Ok(()); } "turn.failed" => { let error = event .data .get("error") .and_then(Value::as_str) .unwrap_or("unknown turn failure"); seq_bridge.emit_runtime_event( session_id, &event.id, "turn_failed", false, Some(error), event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![( "error_class".to_string(), classify_error_text(error).to_string(), )], ); bail!("everruns turn failed: {}", error); } _ => { seq_bridge.emit_runtime_event( session_id, &event.id, &format!("event_{}", event.event_type.replace(['.', '-', ' '], "_")), true, None, event_start_ns, end_unix_nanos(event_start_ns, event_started), vec![], ); } } } if !did_work { thread::sleep(Duration::from_millis(poll_ms.max(25))); } } } fn is_sse_unavailable_error(err: &anyhow::Error) -> bool { let text = err.to_string().to_ascii_lowercase(); text.contains("sse endpoint unavailable") } fn classify_error_text(err: &str) -> &'static str { let lower = err.to_ascii_lowercase(); if lower.contains("timed out") || lower.contains("timeout") { return "timeout"; } if lower.contains("failed to connect") || lower.contains("unreachable") { return "connectivity"; } if lower.contains("mutex poisoned") { return "concurrency"; } if lower.contains("failed to parse") || lower.contains("invalid json") { return "parse"; } if lower.contains("unsupported") { return "unsupported"; } if lower.contains("turn failed") { return "turn_failed"; } "runtime_error" } fn emit_qa_pair_signal( session_id: &str, input_message_id: &str, event_id: &str, prompt: &str, output: &str, ) { rl_signals::emit(json!({ "event_type": "everruns.qa_pair", "runtime": "everruns", "session_id": session_id, "input_message_id": input_message_id, "event_id": event_id, "ok": !output.trim().is_empty(), "prompt_text": text_signal_payload(prompt), "response_text": text_signal_payload(output), })); } #[derive(Clone, Copy, Debug)] enum SignalTextMode { Off, Snippet, Full, } fn signal_text_mode() -> SignalTextMode { let raw = std::env::var("FLOW_RL_SIGNAL_TEXT") .unwrap_or_else(|_| "snippet".to_string()) .to_ascii_lowercase(); match raw.as_str() { "0" | "off" | "none" | "false" => SignalTextMode::Off, "full" | "all" => SignalTextMode::Full, _ => SignalTextMode::Snippet, } } fn signal_text_max_chars() -> usize { std::env::var("FLOW_RL_SIGNAL_MAX_CHARS") .ok() .and_then(|raw| raw.parse::<usize>().ok()) .unwrap_or(4000) .clamp(256, 100_000) } fn text_signal_payload(text: &str) -> Value { let trimmed = text.trim(); let chars = trimmed.chars().count(); let max_chars = signal_text_max_chars(); match signal_text_mode() { SignalTextMode::Off => json!({ "chars": chars, "captured": false, }), SignalTextMode::Snippet => { let snippet: String = trimmed.chars().take(max_chars).collect(); json!({ "chars": chars, "captured": true, "truncated": chars > max_chars, "text": snippet, }) } SignalTextMode::Full => json!({ "chars": chars, "captured": true, "truncated": false, "text": trimmed, }), } } fn extract_output_text(data: &Value) -> Option<String> { let content = data .get("message") .and_then(|m| m.get("content")) .and_then(Value::as_array)?; let mut out = Vec::new(); for part in content { if let Some(text) = part.get("text").and_then(Value::as_str) && !text.trim().is_empty() { out.push(text.to_string()); } } if out.is_empty() { None } else { Some(out.join("\n")) } } fn resolve_session_id(api: &EverrunsApi, resolved: &ResolvedSettings) -> Result<String> { if let Some(session_id) = resolved.session_id.as_ref() { if !resolved.no_seq_tools { eprintln!( "note: reusing session {} (seq tools are not injected for existing sessions)", session_id ); } return Ok(session_id.clone()); } let harness_id = if let Some(id) = resolved.harness_id.clone() { id } else { api.pick_first_harness_id()? }; let agent_id = resolved .agent_id .clone() .or_else(|| api.pick_first_agent_id().ok()); let mut body = Map::new(); body.insert("harness_id".to_string(), Value::String(harness_id.clone())); if let Some(agent_id) = agent_id { body.insert("agent_id".to_string(), Value::String(agent_id)); } if let Some(model_id) = resolved.model_id.clone() { body.insert("model_id".to_string(), Value::String(model_id)); } if !resolved.no_seq_tools { body.insert("tools".to_string(), Value::Array(bridge_tool_definitions())); } let session_id = api.create_session(Value::Object(body))?; eprintln!("created session {} (harness_id={})", session_id, harness_id); Ok(session_id) } #[derive(Debug, Clone)] struct ResolvedSettings { base_url: String, api_key: Option<String>, session_id: Option<String>, agent_id: Option<String>, harness_id: Option<String>, model_id: Option<String>, poll_ms: u64, wait_timeout_secs: u64, seq_socket: PathBuf, seq_timeout_ms: u64, no_seq_tools: bool, } impl ResolvedSettings { fn from_opts(opts: &AiEverrunsOpts) -> Result<Self> { let cfg = load_project_everruns_config(); let api_key_env = env_non_empty("FLOW_EVERRUNS_API_KEY_ENV") .or_else(|| cfg.as_ref().and_then(|c| c.api_key_env.clone())) .unwrap_or_else(|| DEFAULT_EVERRUNS_API_KEY_ENV.to_string()); let base_url = first_non_empty( opts.base_url.clone(), env_non_empty("FLOW_EVERRUNS_BASE_URL") .or_else(|| env_non_empty("EVERRUNS_BASE_URL")) .or_else(|| cfg.as_ref().and_then(|c| c.base_url.clone())) .or_else(|| Some(DEFAULT_EVERRUNS_BASE_URL.to_string())), ) .unwrap_or_else(|| DEFAULT_EVERRUNS_BASE_URL.to_string()); let base_url = normalize_base_url(&base_url)?; let api_key = first_non_empty( opts.api_key.clone(), env_non_empty("FLOW_EVERRUNS_API_KEY") .or_else(|| env_non_empty(&api_key_env)) .or_else(|| env_non_empty("EVERRUNS_API_KEY")), ); let session_id = first_non_empty( opts.session_id.clone(), env_non_empty("FLOW_EVERRUNS_SESSION_ID") .or_else(|| env_non_empty("EVERRUNS_SESSION_ID")) .or_else(|| cfg.as_ref().and_then(|c| c.session_id.clone())), ); let agent_id = first_non_empty( opts.agent_id.clone(), env_non_empty("FLOW_EVERRUNS_AGENT_ID") .or_else(|| env_non_empty("EVERRUNS_AGENT_ID")) .or_else(|| cfg.as_ref().and_then(|c| c.agent_id.clone())), ); let harness_id = first_non_empty( opts.harness_id.clone(), env_non_empty("FLOW_EVERRUNS_HARNESS_ID") .or_else(|| env_non_empty("EVERRUNS_HARNESS_ID")) .or_else(|| cfg.as_ref().and_then(|c| c.harness_id.clone())), ); let model_id = first_non_empty( opts.model_id.clone(), env_non_empty("FLOW_EVERRUNS_MODEL_ID") .or_else(|| env_non_empty("EVERRUNS_MODEL_ID")) .or_else(|| cfg.as_ref().and_then(|c| c.model_id.clone())), ); let seq_socket = resolve_seq_socket_path(opts.seq_socket.clone()); Ok(Self { base_url, api_key, session_id, agent_id, harness_id, model_id, poll_ms: opts.poll_ms.max(25), wait_timeout_secs: opts.wait_timeout_secs.max(1), seq_socket, seq_timeout_ms: opts.seq_timeout_ms.max(1), no_seq_tools: opts.no_seq_tools, }) } } fn normalize_base_url(raw: &str) -> Result<String> { let mut url = raw.trim().to_string(); if url.is_empty() { bail!("Everruns base URL is empty"); } while url.ends_with('/') { url.pop(); } if !url.starts_with("http://") && !url.starts_with("https://") { bail!( "invalid Everruns base URL '{}': must start with http:// or https://", raw ); } Ok(url) } fn resolve_seq_socket_path(cli_socket: Option<PathBuf>) -> PathBuf { if let Some(path) = cli_socket { return path; } if let Some(path) = env_non_empty("SEQ_SOCKET_PATH") { return PathBuf::from(path); } if let Some(path) = env_non_empty("SEQD_SOCKET") { return PathBuf::from(path); } PathBuf::from(DEFAULT_SEQ_SOCKET) } fn load_project_everruns_config() -> Option<EverrunsConfig> { let cwd = std::env::current_dir().ok()?; let flow_toml = find_flow_toml_upwards(&cwd)?; let cfg = config::load(flow_toml).ok()?; cfg.everruns } fn find_flow_toml_upwards(start: &Path) -> Option<PathBuf> { let mut current = Some(start.to_path_buf()); while let Some(dir) = current { let candidate = dir.join("flow.toml"); if candidate.exists() { return Some(candidate); } current = dir.parent().map(Path::to_path_buf); } None } fn first_non_empty(a: Option<String>, b: Option<String>) -> Option<String> { for candidate in [a, b].into_iter().flatten() { let trimmed = candidate.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } None } fn env_non_empty(name: &str) -> Option<String> { let value = std::env::var(name).ok()?; let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } #[derive(Clone)] struct EverrunsApi { client: Client, sse_client: Client, base_url: String, api_key: Option<String>, } impl EverrunsApi { fn new(base_url: String, api_key: Option<String>) -> Result<Self> { let client = Client::builder() .timeout(Duration::from_secs(30)) .build() .context("failed to build Everruns HTTP client")?; let sse_client = Client::builder() .connect_timeout(Duration::from_secs(5)) .build() .context("failed to build Everruns SSE HTTP client")?; Ok(Self { client, sse_client, base_url, api_key, }) } fn pick_first_harness_id(&self) -> Result<String> { let value = self.get_json("/v1/harnesses", &[])?; let resp: ListResponse<ResourceStub> = serde_json::from_value(value).context("failed to decode harness list response")?; resp.data .into_iter() .find(|h| h.status.as_deref() != Some("disabled")) .map(|h| h.id) .ok_or_else(|| anyhow::anyhow!("no harnesses found in Everruns")) } fn pick_first_agent_id(&self) -> Result<String> { let value = self.get_json("/v1/agents", &[])?; let resp: ListResponse<ResourceStub> = serde_json::from_value(value).context("failed to decode agent list response")?; resp.data .into_iter() .find(|a| a.status.as_deref() != Some("archived")) .map(|a| a.id) .ok_or_else(|| anyhow::anyhow!("no agents found in Everruns")) } fn create_session(&self, body: Value) -> Result<String> { let value = self.post_json("/v1/sessions", body)?; value .get("id") .and_then(Value::as_str) .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("Everruns create session response missing id")) } fn post_message(&self, session_id: &str, prompt: &str) -> Result<String> { let path = format!("/v1/sessions/{}/messages", session_id); let payload = json!({ "message": { "content": [ { "type": "text", "text": prompt } ] } }); let value = self.post_json(&path, payload)?; value .get("id") .and_then(Value::as_str) .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("Everruns create message response missing id")) } fn list_events(&self, session_id: &str, since_id: Option<&str>) -> Result<Vec<EverrunsEvent>> { let path = format!("/v1/sessions/{}/events", session_id); let mut query: Vec<(&str, String)> = vec![ ("exclude", "output.message.delta".to_string()), ("exclude", "reason.thinking.delta".to_string()), ]; if let Some(since_id) = since_id { query.push(("since_id", since_id.to_string())); } let value = self.get_json(&path, &query)?; let resp: ListResponse<EverrunsEvent> = serde_json::from_value(value).context("failed to decode events response")?; Ok(resp.data) } fn submit_tool_results( &self, session_id: &str, tool_results: Vec<SubmitToolResult>, ) -> Result<()> { let path = format!("/v1/sessions/{}/tool-results", session_id); let payload = SubmitToolResultsRequest { tool_results }; let _ = self.post_json(&path, serde_json::to_value(payload)?)?; Ok(()) } fn read_sse_batch( &self, session_id: &str, since_id: Option<&str>, timeout: Duration, ) -> Result<SseBatch> { let path = format!("/v1/sessions/{}/sse", session_id); let url = format!("{}{}", self.base_url, path); let mut query: Vec<(&str, String)> = vec![ ("exclude", "output.message.delta".to_string()), ("exclude", "reason.thinking.delta".to_string()), ]; if let Some(since_id) = since_id { query.push(("since_id", since_id.to_string())); } let request = self .with_auth(self.sse_client.get(url)) .query(&query) .header("accept", "text/event-stream") .timeout(timeout); let response = request .send() .with_context(|| format!("Everruns API GET {} request failed", path))?; let status = response.status(); if !status.is_success() { let body = response.text().unwrap_or_default(); if status.as_u16() == 404 || status.as_u16() == 405 || status.as_u16() == 501 { bail!( "sse endpoint unavailable: Everruns API GET {} returned {}: {}", path, status, body ); } bail!("Everruns API GET {} returned {}: {}", path, status, body); } let mut reader = BufReader::new(response); let mut line = String::new(); let mut current_event = String::new(); let mut current_data: Vec<String> = Vec::new(); let mut out = Vec::new(); let mut saw_disconnect = false; loop { line.clear(); let n = reader .read_line(&mut line) .with_context(|| format!("failed to read Everruns SSE line from {}", path))?; if n == 0 { break; } let raw = line.trim_end_matches(&['\r', '\n'][..]); if raw.is_empty() { match decode_sse_frame(¤t_event, ¤t_data.join("\n"))? { SseFrame::Event(event) => out.push(event), SseFrame::Disconnecting => { saw_disconnect = true; break; } SseFrame::Ignore => {} } current_event.clear(); current_data.clear(); continue; } if raw.starts_with(':') { continue; } if let Some(rest) = raw.strip_prefix("event:") { current_event = rest.trim().to_string(); continue; } if let Some(rest) = raw.strip_prefix("data:") { current_data.push(rest.trim_start().to_string()); continue; } } if !current_event.is_empty() || !current_data.is_empty() { match decode_sse_frame(¤t_event, ¤t_data.join("\n"))? { SseFrame::Event(event) => out.push(event), SseFrame::Disconnecting => { saw_disconnect = true; } SseFrame::Ignore => {} } } Ok(SseBatch { events: out, saw_disconnect, }) } fn get_json(&self, path: &str, query: &[(&str, String)]) -> Result<Value> { let url = format!("{}{}", self.base_url, path); let request = self.with_auth(self.client.get(url)).query(query); self.send_json(request, "GET", path) } fn post_json(&self, path: &str, body: Value) -> Result<Value> { let url = format!("{}{}", self.base_url, path); let request = self.with_auth(self.client.post(url)).json(&body); self.send_json(request, "POST", path) } fn with_auth(&self, request: RequestBuilder) -> RequestBuilder { if let Some(api_key) = self.api_key.as_deref() { request.bearer_auth(api_key) } else { request } } fn send_json(&self, request: RequestBuilder, method: &str, path: &str) -> Result<Value> { let response = request .send() .with_context(|| format!("Everruns API {} {} request failed", method, path))?; let status = response.status(); let body = response.text().with_context(|| { format!( "Everruns API {} {} failed to read response body", method, path ) })?; if !status.is_success() { bail!( "Everruns API {} {} returned {}: {}", method, path, status, body ); } serde_json::from_str(&body).with_context(|| { format!( "Everruns API {} {} returned invalid JSON: {}", method, path, body ) }) } } struct SeqBridge { client: std::sync::Mutex<SeqClient>, maple_exporter: Option<MapleTraceExporter>, } impl SeqBridge { fn connect(settings: &ResolvedSettings) -> Result<Self> { let timeout = Duration::from_millis(settings.seq_timeout_ms); let client = SeqClient::connect_with_timeout(&settings.seq_socket, timeout).with_context(|| { format!( "failed to connect to seqd at {}", settings.seq_socket.display() ) })?; let maple_exporter = MapleTraceExporter::from_env().context("invalid SEQ_EVERRUNS_MAPLE_* configuration")?; if maple_exporter.is_some() { eprintln!("maple dual-ingest telemetry enabled"); } Ok(Self { client: std::sync::Mutex::new(client), maple_exporter, }) } fn execute_tool_call( &self, session_id: &str, event_id: &str, call: BridgeToolCall, ) -> SubmitToolResult { let started = Instant::now(); let start_unix_nano = unix_time_nanos_now(); let mut seq_op = "unknown".to_string(); let result = match bridge_build_request(session_id, event_id, &call) { Ok(ext_req) => { seq_op = ext_req.op.clone(); let req = RpcRequest { op: ext_req.op, args: ext_req.args, request_id: ext_req.request_id, run_id: ext_req.run_id, tool_call_id: ext_req.tool_call_id, }; let result_call_id = req .tool_call_id .as_ref() .cloned() .unwrap_or_else(|| call.id.clone()); match self.client.lock() { Ok(mut client) => match client.call(&req) { Ok(resp) => { if resp.ok { SubmitToolResult { tool_call_id: result_call_id, result: Some(resp.result.unwrap_or_else(|| json!({}))), error: None, } } else { SubmitToolResult { tool_call_id: result_call_id, result: None, error: Some(resp.error.unwrap_or_else(|| { format!("seq {} failed with unknown error", seq_op) })), } } } Err(error) => SubmitToolResult { tool_call_id: result_call_id, result: None, error: Some(format!("seq {} call failed: {}", seq_op, error)), }, }, Err(_) => SubmitToolResult { tool_call_id: result_call_id, result: None, error: Some("seq client mutex poisoned".to_string()), }, } } Err(err) => SubmitToolResult { tool_call_id: call.id.clone(), result: None, error: Some(err.to_string()), }, }; let elapsed = started.elapsed(); let duration_ms = elapsed.as_millis() as u64; let end_unix_nano = start_unix_nano.saturating_add(elapsed.as_nanos() as u64); rl_signals::emit(json!({ "event_type": "everruns.tool_call_result", "runtime": "everruns", "session_id": session_id, "event_id": event_id, "tool_call_id": result.tool_call_id, "tool_name": call.name, "seq_op": seq_op, "ok": result.error.is_none(), "error": result.error, "error_class": result.error.as_deref().map(classify_error_text), "duration_ms": duration_ms, })); if let Some(exporter) = self.maple_exporter.as_ref() { let span = MapleSpan::for_tool_call( session_id, event_id, &result.tool_call_id, &call.name, &seq_op, result.error.is_none(), result.error.as_deref(), start_unix_nano, end_unix_nano, duration_ms, ); exporter.emit_span(span); } result } fn emit_runtime_event( &self, session_id: &str, event_id: &str, stage: &str, ok: bool, error: Option<&str>, start_unix_nano: u64, end_unix_nano: u64, extra_attributes: Vec<(String, String)>, ) { let duration_ms = (end_unix_nano.saturating_sub(start_unix_nano)) / 1_000_000; let attrs_obj = rl_signals::attrs_to_object(extra_attributes.clone()); rl_signals::emit(json!({ "event_type": "everruns.runtime_event", "runtime": "everruns", "session_id": session_id, "event_id": event_id, "stage": stage, "ok": ok, "error": error, "error_class": error.map(classify_error_text), "duration_ms": duration_ms, "attrs": attrs_obj, })); if let Some(exporter) = self.maple_exporter.as_ref() { let span = MapleSpan::for_runtime_event( session_id, event_id, stage, ok, error, start_unix_nano, end_unix_nano, extra_attributes, ); exporter.emit_span(span); } } } fn unix_time_nanos_now() -> u64 { match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(dur) => dur.as_nanos() as u64, Err(_) => 0, } } fn end_unix_nanos(start_unix_nano: u64, started: Instant) -> u64 { start_unix_nano.saturating_add(started.elapsed().as_nanos() as u64) } #[derive(Debug, Deserialize)] struct ListResponse<T> { data: Vec<T>, } #[derive(Debug, Deserialize)] struct ResourceStub { id: String, #[serde(default)] status: Option<String>, } #[derive(Debug, Deserialize)] struct EverrunsEvent { id: String, #[serde(rename = "type")] event_type: String, #[serde(default)] context: EventContext, #[serde(default)] data: Value, } #[derive(Debug, Default, Deserialize)] struct EventContext { #[serde(default)] input_message_id: Option<String>, } #[derive(Debug, Serialize)] struct SubmitToolResultsRequest { tool_results: Vec<SubmitToolResult>, } #[derive(Debug, Serialize)] struct SubmitToolResult { tool_call_id: String, #[serde(skip_serializing_if = "Option::is_none")] result: Option<Value>, #[serde(skip_serializing_if = "Option::is_none")] error: Option<String>, } struct SseBatch { events: Vec<EverrunsEvent>, saw_disconnect: bool, } enum SseFrame { Event(EverrunsEvent), Disconnecting, Ignore, } fn decode_sse_frame(event_type: &str, data: &str) -> Result<SseFrame> { if event_type.is_empty() { return Ok(SseFrame::Ignore); } let normalized = event_type.trim().to_ascii_lowercase(); if normalized == "connected" { return Ok(SseFrame::Ignore); } if normalized == "disconnecting" { return Ok(SseFrame::Disconnecting); } if data.trim().is_empty() { return Ok(SseFrame::Ignore); } let parsed: EverrunsEvent = serde_json::from_str(data).with_context(|| { format!( "failed to decode Everruns SSE event '{}' payload as JSON", event_type ) })?; Ok(SseFrame::Event(parsed)) } ================================================ FILE: src/ai_server.rs ================================================ //! Simple AI server client for task matching. use std::collections::HashMap; use std::env; use std::thread; use std::time::Duration; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use crate::env as flow_env; const DEFAULT_URL: &str = "http://127.0.0.1:7331"; const MAX_RETRIES: usize = 3; #[derive(Debug, Serialize)] struct ChatRequest { model: String, messages: Vec<ChatMessage>, temperature: f32, } #[derive(Debug, Serialize)] struct ChatMessage { role: String, content: String, } #[derive(Debug, Deserialize)] struct ChatResponse { choices: Vec<Choice>, } #[derive(Debug, Deserialize)] struct Choice { message: Option<ResponseMessage>, text: Option<String>, } #[derive(Debug, Deserialize)] struct ResponseMessage { content: String, } #[derive(Debug, Deserialize)] struct ModelsResponse { data: Vec<ModelInfo>, } #[derive(Debug, Deserialize)] struct ModelInfo { id: String, } struct AiServerConfig { base_url: String, model: String, token: Option<String>, } /// Send a prompt to the AI server and return a response. pub fn quick_prompt( prompt: &str, model: Option<&str>, url: Option<&str>, token: Option<&str>, ) -> Result<String> { let prompt = prompt.trim(); if prompt.is_empty() { bail!("Prompt is empty."); } let cfg = resolve_ai_config(model, url, token)?; let client = Client::builder() .timeout(Duration::from_secs(30)) .build() .context("failed to create HTTP client")?; let endpoint = format!("{}/v1/chat/completions", cfg.base_url); let body = ChatRequest { model: cfg.model, messages: vec![ChatMessage { role: "user".to_string(), content: prompt.to_string(), }], temperature: 0.1, }; let mut last_error: Option<anyhow::Error> = None; for attempt in 1..=MAX_RETRIES { let mut req = client.post(&endpoint).json(&body); if let Some(token) = cfg.token.as_deref() { req = req.bearer_auth(token); } let resp = match req .send() .with_context(|| format!("failed to connect to AI server at {}", cfg.base_url)) { Ok(resp) => resp, Err(err) => { let retryable = attempt < MAX_RETRIES; if retryable { thread::sleep(Duration::from_millis(300 * attempt as u64)); last_error = Some(err); continue; } return Err(err); } }; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); let retryable_status = status.is_server_error() || status.as_u16() == 429; if retryable_status && attempt < MAX_RETRIES { thread::sleep(Duration::from_millis(300 * attempt as u64)); last_error = Some(anyhow::anyhow!( "AI server returned retryable status {}: {}", status, body )); continue; } bail!("AI server returned status {}: {}", status, body); } let text_body = resp.text().context("failed to read AI server response")?; let parsed: ChatResponse = serde_json::from_str(&text_body).context("failed to parse AI server response")?; let text = parsed .choices .first() .and_then(|c| { c.message .as_ref() .map(|m| m.content.clone()) .or(c.text.clone()) }) .map(|t| t.trim().to_string()) .unwrap_or_default(); return Ok(text); } if let Some(err) = last_error { return Err(err); } bail!("AI request failed unexpectedly without a captured error.") } fn resolve_ai_config( model_override: Option<&str>, url_override: Option<&str>, token_override: Option<&str>, ) -> Result<AiServerConfig> { let mut resolved: HashMap<String, String> = HashMap::new(); let mut missing = Vec::new(); let keys = ["AI_SERVER_URL", "AI_SERVER_MODEL", "AI_SERVER_TOKEN"]; for key in keys { if let Ok(value) = env::var(key) { if !value.trim().is_empty() { resolved.insert(key.to_string(), value); continue; } } missing.push(key.to_string()); } if !missing.is_empty() { if let Ok(vars) = flow_env::fetch_personal_env_vars(&missing) { for (key, value) in vars { if !value.trim().is_empty() { resolved.insert(key, value); } } } } let mut url = url_override .map(|s| s.to_string()) .or_else(|| resolved.get("AI_SERVER_URL").cloned()) .unwrap_or_else(|| DEFAULT_URL.to_string()); if url.trim().is_empty() { url = DEFAULT_URL.to_string(); } let base_url = base_ai_url(&url); let token = token_override .map(|s| s.to_string()) .or_else(|| resolved.get("AI_SERVER_TOKEN").cloned()) .filter(|v| !v.trim().is_empty()); let model = model_override .map(|s| s.to_string()) .or_else(|| resolved.get("AI_SERVER_MODEL").cloned()) .unwrap_or_default(); let model = if model.trim().is_empty() { fetch_default_model(&base_url, token.as_deref())? } else { model }; Ok(AiServerConfig { base_url, model, token, }) } fn fetch_default_model(base_url: &str, token: Option<&str>) -> Result<String> { let client = Client::builder() .timeout(Duration::from_secs(5)) .build() .context("failed to create HTTP client")?; let url = format!("{}/v1/models", base_url); let mut req = client.get(&url); if let Some(token) = token { req = req.bearer_auth(token); } let resp = req .send() .with_context(|| format!("failed to query models at {}", base_url))?; if !resp.status().is_success() { bail!( "AI_SERVER_MODEL not set and /v1/models failed with status {}. Set it with: f env set --personal AI_SERVER_MODEL=<model>", resp.status() ); } let text_body = resp.text().context("failed to read models response")?; let parsed: ModelsResponse = serde_json::from_str(&text_body).context("failed to parse models response")?; let model = parsed .data .into_iter() .find(|m| !m.id.trim().is_empty()) .map(|m| m.id) .unwrap_or_default(); if model.trim().is_empty() { bail!( "AI_SERVER_MODEL not set and no models returned. Set it with: f env set --personal AI_SERVER_MODEL=<model>" ); } Ok(model) } fn base_ai_url(url: &str) -> String { let trimmed = url.trim_end_matches('/'); if let Some(idx) = trimmed.find("/v1/") { return trimmed[..idx].to_string(); } trimmed.to_string() } ================================================ FILE: src/ai_taskd.rs ================================================ use std::collections::HashMap; use std::fs; use std::io::{Read, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; use crate::rl_signals; use crate::{ai_tasks, project_snapshot::AiTaskSnapshot}; const MSGPACK_WIRE_PREFIX: u8 = 0xFF; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WireEncoding { Json, Msgpack, } #[derive(Debug, Clone)] struct CachedDiscovery { tasks: Vec<ai_tasks::DiscoveredAiTask>, refreshed_at: Instant, } #[derive(Debug, Clone)] struct CachedArtifact { binary_path: PathBuf, refreshed_at: Instant, } #[derive(Debug, Default)] struct TaskdState { discoveries: HashMap<PathBuf, CachedDiscovery>, artifacts: HashMap<String, CachedArtifact>, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum TaskdRequest { Ping, Stop, Run { project_root: String, selector: String, args: Vec<String>, no_cache: bool, #[serde(default = "default_capture_output")] capture_output: bool, #[serde(default)] include_timings: bool, #[serde(default)] suggested_task: Option<String>, #[serde(default)] override_reason: Option<String>, }, } fn default_capture_output() -> bool { true } #[derive(Debug, Serialize, Deserialize)] struct TaskdResponse { ok: bool, message: String, exit_code: i32, stdout: String, stderr: String, #[serde(skip_serializing_if = "Option::is_none")] timings: Option<RequestTimings>, } #[derive(Debug, Serialize, Deserialize, Clone)] struct RequestTimings { resolve_selector_us: u64, run_task_us: u64, total_us: u64, used_fast_selector: bool, used_cache: bool, } impl RequestTimings { fn to_kv_line(&self, selector: &str) -> String { format!( "selector={} resolve_us={} run_us={} total_us={} fast_selector={} cache={}", selector, self.resolve_selector_us, self.run_task_us, self.total_us, self.used_fast_selector, self.used_cache, ) } } pub fn start() -> Result<()> { if ping().is_ok() { println!("ai-taskd already running ({})", socket_path().display()); return Ok(()); } let exe = std::env::current_exe().context("failed to resolve current executable")?; let launch = format!( "nohup {} tasks daemon serve >/dev/null 2>&1 &", shell_quote(&exe.to_string_lossy()) ); let status = Command::new("sh") .arg("-lc") .arg(launch) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .context("failed to launch ai-taskd")?; if !status.success() { bail!("failed to launch ai-taskd (status {})", status); } let deadline = Instant::now() + Duration::from_secs(3); while Instant::now() < deadline { if ping().is_ok() { println!("ai-taskd started ({})", socket_path().display()); return Ok(()); } std::thread::sleep(Duration::from_millis(100)); } bail!( "ai-taskd failed to start within timeout (socket: {})", socket_path().display() ) } pub fn stop() -> Result<()> { let response = match send_request(&TaskdRequest::Stop, WireEncoding::Json) { Ok(response) => response, Err(error) => { let message = format!("{error:#}"); if message.contains("Connection refused") || message.contains("No such file or directory") { fs::remove_file(socket_path()).ok(); fs::remove_file(pid_path()).ok(); println!("ai-taskd already stopped"); return Ok(()); } return Err(error); } }; if response.ok { println!("{}", response.message); Ok(()) } else { bail!(response.message) } } pub fn status() -> Result<()> { if ping().is_ok() { println!("ai-taskd: running ({})", socket_path().display()); } else { println!("ai-taskd: stopped ({})", socket_path().display()); } Ok(()) } pub fn run_via_daemon( project_root: &Path, selector: &str, args: &[String], no_cache: bool, ) -> Result<()> { let request = TaskdRequest::Run { project_root: project_root.to_string_lossy().to_string(), selector: selector.to_string(), args: args.to_vec(), no_cache, capture_output: true, include_timings: false, suggested_task: read_optional_env("FLOW_ROUTER_SUGGESTED_TASK"), override_reason: read_optional_env("FLOW_ROUTER_OVERRIDE_REASON"), }; let response = send_request(&request, WireEncoding::Json)?; if !response.stdout.is_empty() { print!("{}", response.stdout); } if !response.stderr.is_empty() { eprint!("{}", response.stderr); } if response.ok { Ok(()) } else { bail!(response.message) } } pub fn serve() -> Result<()> { let socket = socket_path(); if let Some(parent) = socket.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } if socket.exists() { fs::remove_file(&socket) .with_context(|| format!("failed to remove stale socket {}", socket.display()))?; } let listener = UnixListener::bind(&socket) .with_context(|| format!("failed to bind ai-taskd socket {}", socket.display()))?; if let Some(pid_parent) = pid_path().parent() { fs::create_dir_all(pid_parent) .with_context(|| format!("failed to create {}", pid_parent.display()))?; } fs::write(pid_path(), std::process::id().to_string()) .with_context(|| format!("failed to write {}", pid_path().display()))?; let mut should_stop = false; let mut state = TaskdState::default(); while !should_stop { let (mut stream, _) = match listener.accept() { Ok(tuple) => tuple, Err(error) => { eprintln!("warning: ai-taskd accept failed: {}", error); continue; } }; let mut payload = Vec::new(); if let Err(error) = stream.read_to_end(&mut payload) { write_error_response( &mut stream, format!("ai-taskd read request failed: {error}"), WireEncoding::Json, ); continue; } let (request, encoding): (TaskdRequest, WireEncoding) = match decode_request(&payload) { Ok(request) => request, Err(error) => { write_error_response( &mut stream, format!("ai-taskd invalid request payload: {error}"), infer_encoding_from_payload(&payload), ); continue; } }; let response = handle_request(&request, &mut state); if matches!(request, TaskdRequest::Stop) { should_stop = true; } let body = match encode_response(&response, encoding) { Ok(body) => body, Err(error) => { write_error_response( &mut stream, format!("ai-taskd encode response failed: {error}"), encoding, ); continue; } }; if let Err(error) = stream.write_all(&body) { eprintln!("warning: ai-taskd write response failed: {}", error); continue; } stream.flush().ok(); } fs::remove_file(&socket).ok(); fs::remove_file(pid_path()).ok(); Ok(()) } fn handle_request(request: &TaskdRequest, state: &mut TaskdState) -> TaskdResponse { match request { TaskdRequest::Ping => TaskdResponse { ok: true, message: "pong".to_string(), exit_code: 0, stdout: String::new(), stderr: String::new(), timings: None, }, TaskdRequest::Stop => TaskdResponse { ok: true, message: "ai-taskd stopping".to_string(), exit_code: 0, stdout: String::new(), stderr: String::new(), timings: None, }, TaskdRequest::Run { project_root, selector, args, no_cache, capture_output, include_timings, suggested_task, override_reason, } => { let root = PathBuf::from(project_root); match run_request( state, &root, selector, args, *no_cache, *capture_output, suggested_task.as_deref(), override_reason.as_deref(), ) { Ok(result) => { if (*include_timings || timings_log_enabled()) && let Some(timings) = result.timings.as_ref() && timings_log_enabled() { eprintln!("[ai-taskd][timings] {}", timings.to_kv_line(selector)); } TaskdResponse { ok: result.code == 0, message: if result.code == 0 { format!("ai task '{}' completed", selector) } else { format!("ai task '{}' failed with status {}", selector, result.code) }, exit_code: result.code, stdout: result.stdout, stderr: result.stderr, timings: if *include_timings { result.timings } else { None }, } } Err(e) => TaskdResponse { ok: false, message: format!("ai-taskd run failed: {e}"), exit_code: 1, stdout: String::new(), stderr: String::new(), timings: None, }, } } } } struct RunRequestOutcome { code: i32, stdout: String, stderr: String, timings: Option<RequestTimings>, } fn discovery_ttl() -> Duration { let ms = std::env::var("FLOW_AI_TASKD_DISCOVERY_TTL_MS") .ok() .and_then(|raw| raw.trim().parse::<u64>().ok()) .unwrap_or(750); Duration::from_millis(ms) } fn artifact_ttl() -> Duration { let ms = std::env::var("FLOW_AI_TASKD_ARTIFACT_TTL_MS") .ok() .and_then(|raw| raw.trim().parse::<u64>().ok()) .unwrap_or(1500); Duration::from_millis(ms) } fn discovery_key(project_root: &Path) -> PathBuf { project_root .canonicalize() .unwrap_or_else(|_| project_root.to_path_buf()) } fn ensure_discovery_fresh(state: &mut TaskdState, key: &Path) -> Result<bool> { if let Some(entry) = state.discoveries.get(key) && entry.refreshed_at.elapsed() <= discovery_ttl() { return Ok(true); } refresh_discovery(state, key)?; Ok(false) } fn refresh_discovery(state: &mut TaskdState, key: &Path) -> Result<()> { let tasks = AiTaskSnapshot::from_canonical_root(key.to_path_buf())?.tasks; state.discoveries.insert( key.to_path_buf(), CachedDiscovery { tasks, refreshed_at: Instant::now(), }, ); Ok(()) } fn run_request( state: &mut TaskdState, project_root: &Path, selector: &str, args: &[String], no_cache: bool, capture_output: bool, suggested_task: Option<&str>, override_reason: Option<&str>, ) -> Result<RunRequestOutcome> { let started = Instant::now(); let resolve_started = Instant::now(); let mut used_fast_selector = true; let mut selected = ai_tasks::resolve_task_fast(project_root, selector)?; if selected.is_none() { used_fast_selector = false; let key = discovery_key(project_root); let from_cache = ensure_discovery_fresh(state, &key)?; let tasks = state .discoveries .get(&key) .map(|entry| entry.tasks.as_slice()) .unwrap_or(&[]); selected = ai_tasks::select_task(tasks, selector)?.cloned(); if selected.is_none() && from_cache { // If cache was stale, refresh once and retry task selection. refresh_discovery(state, &key)?; let fresh = state .discoveries .get(&key) .map(|entry| entry.tasks.as_slice()) .unwrap_or(&[]); selected = ai_tasks::select_task(fresh, selector)?.cloned(); } } let task = selected.with_context(|| format!("AI task '{}' not found", selector))?; let resolve_selector_us = resolve_started.elapsed().as_micros() as u64; let decision_id = Uuid::new_v4().simple().to_string(); let session_id = router_session_id(project_root); let context_path = project_root .canonicalize() .unwrap_or_else(|_| project_root.to_path_buf()) .display() .to_string(); emit_router_decision( &decision_id, &session_id, selector, &task.id, &context_path, args, no_cache, capture_output, used_fast_selector, resolve_selector_us, ); if let Some(suggested) = suggested_task && !same_task_selector(suggested, &task.id) { emit_router_override( &decision_id, &session_id, suggested, &task.id, override_reason.unwrap_or("manual_selector_override"), ); } let run_started = Instant::now(); let used_cache = !no_cache; if !capture_output && !no_cache { let status = run_cached_task_status_hot(state, project_root, &task, args); match status { Ok(status) => { let run_task_us = run_started.elapsed().as_micros() as u64; let total_us = started.elapsed().as_micros() as u64; let code = status.code().unwrap_or(1); emit_router_outcome( &decision_id, &session_id, &task.id, code, (run_task_us / 1000) as u64, "", used_cache, used_fast_selector, resolve_selector_us, run_task_us, total_us, ); return Ok(RunRequestOutcome { code, stdout: String::new(), stderr: String::new(), timings: Some(RequestTimings { resolve_selector_us, run_task_us, total_us, used_fast_selector, used_cache, }), }); } Err(err) => { let run_task_us = run_started.elapsed().as_micros() as u64; emit_router_outcome( &decision_id, &session_id, &task.id, 1, (run_task_us / 1000) as u64, &classify_error(&err.to_string()), used_cache, used_fast_selector, resolve_selector_us, run_task_us, started.elapsed().as_micros() as u64, ); return Err(err); } } } let output = if no_cache { ai_tasks::run_task_via_moon_output(&task, project_root, args) } else { run_cached_task_output_hot(state, project_root, &task, args) }; let output = match output { Ok(output) => output, Err(err) => { let run_task_us = run_started.elapsed().as_micros() as u64; emit_router_outcome( &decision_id, &session_id, &task.id, 1, (run_task_us / 1000) as u64, &classify_error(&err.to_string()), used_cache, used_fast_selector, resolve_selector_us, run_task_us, started.elapsed().as_micros() as u64, ); return Err(err); } }; let code = output.status.code().unwrap_or(1); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let run_task_us = run_started.elapsed().as_micros() as u64; let total_us = started.elapsed().as_micros() as u64; emit_router_outcome( &decision_id, &session_id, &task.id, code, (run_task_us / 1000) as u64, "", used_cache, used_fast_selector, resolve_selector_us, run_task_us, total_us, ); Ok(RunRequestOutcome { code, stdout, stderr, timings: Some(RequestTimings { resolve_selector_us, run_task_us, total_us, used_fast_selector, used_cache, }), }) } fn router_session_id(project_root: &Path) -> String { if let Ok(raw) = std::env::var("FLOW_ROUTER_SESSION_ID") { let trimmed = raw.trim(); if !trimmed.is_empty() { return trimmed.to_string(); } } let root_name = project_root .file_name() .map(|value| value.to_string_lossy().to_string()) .unwrap_or_else(|| "flow".to_string()); format!("ai-taskd-{}-{}", std::process::id(), root_name) } fn emit_router_decision( decision_id: &str, session_id: &str, selector: &str, chosen_task: &str, project_path: &str, args: &[String], no_cache: bool, capture_output: bool, used_fast_selector: bool, resolve_selector_us: u64, ) { rl_signals::emit(json!({ "event_type": "flow.router.decision.v1", "runtime": "flow-router", "source": "flow.ai_taskd", "session_id": session_id, "event_id": format!("decision-{}", decision_id), "decision_id": decision_id, "ok": true, "subject": { "schema_version": "flow_router_decision_v1", "decision_id": decision_id, "source": "flow.ai_taskd", "session_id": session_id, "project_path": project_path, "project_fingerprint": project_path, "chosen_task": chosen_task, "confidence": 1.0, "user_intent": selector, "candidates": [{ "task": chosen_task, "score": 1.0, }], "context": { "args": args, "no_cache": no_cache, "capture_output": capture_output, "used_fast_selector": used_fast_selector, "resolve_selector_us": resolve_selector_us, } } })); } fn emit_router_override( decision_id: &str, session_id: &str, original_task: &str, override_task: &str, reason: &str, ) { rl_signals::emit(json!({ "event_type": "flow.router.override.v1", "runtime": "flow-router", "source": "flow.ai_taskd", "session_id": session_id, "event_id": format!("override-{}", decision_id), "decision_id": decision_id, "ok": true, "subject": { "schema_version": "flow_router_override_v1", "decision_id": decision_id, "source": "flow.ai_taskd", "session_id": session_id, "original_task": original_task, "override_task": override_task, "reason": reason, } })); } #[allow(clippy::too_many_arguments)] fn emit_router_outcome( decision_id: &str, session_id: &str, task_executed: &str, exit_code: i32, time_to_resolution_ms: u64, error_kind: &str, used_cache: bool, used_fast_selector: bool, resolve_selector_us: u64, run_task_us: u64, total_us: u64, ) { let outcome = if exit_code == 0 { "success" } else { "failure" }; rl_signals::emit(json!({ "event_type": "flow.router.outcome.v1", "runtime": "flow-router", "source": "flow.ai_taskd", "session_id": session_id, "event_id": format!("outcome-{}", decision_id), "decision_id": decision_id, "ok": exit_code == 0, "subject": { "schema_version": "flow_router_outcome_v1", "decision_id": decision_id, "source": "flow.ai_taskd", "session_id": session_id, "outcome": outcome, "task_executed": task_executed, "time_to_resolution_ms": time_to_resolution_ms, "manual_override_task": "", "error_kind": error_kind, "extra": { "exit_code": exit_code, "used_cache": used_cache, "used_fast_selector": used_fast_selector, "resolve_selector_us": resolve_selector_us, "run_task_us": run_task_us, "total_us": total_us, } } })); } fn classify_error(message: &str) -> String { let lower = message.to_ascii_lowercase(); if lower.contains("not found") { return "not_found".to_string(); } if lower.contains("timeout") { return "timeout".to_string(); } if lower.contains("permission denied") { return "permission_denied".to_string(); } if lower.contains("connection refused") || lower.contains("failed to connect") { return "connection_error".to_string(); } "runtime_error".to_string() } fn read_optional_env(key: &str) -> Option<String> { let raw = std::env::var(key).ok()?; let trimmed = raw.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } fn normalize_selector_for_compare(value: &str) -> String { let mut out = value.trim().to_ascii_lowercase(); if let Some(stripped) = out.strip_prefix("ai:") { out = stripped.to_string(); } out } fn same_task_selector(a: &str, b: &str) -> bool { normalize_selector_for_compare(a) == normalize_selector_for_compare(b) } fn run_cached_task_output_hot( state: &mut TaskdState, project_root: &Path, task: &ai_tasks::DiscoveredAiTask, args: &[String], ) -> Result<std::process::Output> { let canonical_root = project_root .canonicalize() .unwrap_or_else(|_| project_root.to_path_buf()); let key = format!("{}::{}", canonical_root.display(), task.id); if let Some(entry) = state.artifacts.get(&key) && entry.refreshed_at.elapsed() <= artifact_ttl() && entry.binary_path.exists() { return run_artifact_output(&entry.binary_path, &canonical_root, &task.id, args); } let artifact = ai_tasks::build_task_cached(task, &canonical_root, false)?; let binary_path = artifact.binary_path.clone(); state.artifacts.insert( key, CachedArtifact { binary_path: binary_path.clone(), refreshed_at: Instant::now(), }, ); run_artifact_output(&binary_path, &canonical_root, &task.id, args) } fn run_cached_task_status_hot( state: &mut TaskdState, project_root: &Path, task: &ai_tasks::DiscoveredAiTask, args: &[String], ) -> Result<std::process::ExitStatus> { let canonical_root = project_root .canonicalize() .unwrap_or_else(|_| project_root.to_path_buf()); let key = format!("{}::{}", canonical_root.display(), task.id); if let Some(entry) = state.artifacts.get(&key) && entry.refreshed_at.elapsed() <= artifact_ttl() && entry.binary_path.exists() { return run_artifact_status(&entry.binary_path, &canonical_root, &task.id, args); } let artifact = ai_tasks::build_task_cached(task, &canonical_root, false)?; let binary_path = artifact.binary_path.clone(); state.artifacts.insert( key, CachedArtifact { binary_path: binary_path.clone(), refreshed_at: Instant::now(), }, ); run_artifact_status(&binary_path, &canonical_root, &task.id, args) } fn run_artifact_output( binary_path: &Path, project_root: &Path, task_id: &str, args: &[String], ) -> Result<std::process::Output> { let output = Command::new(binary_path) .args(args) .current_dir(project_root) .env( "FLOW_AI_TASK_PROJECT_ROOT", project_root.to_string_lossy().to_string(), ) .output() .with_context(|| { format!( "failed to run cached AI task '{}' binary {}", task_id, binary_path.display() ) })?; Ok(output) } fn run_artifact_status( binary_path: &Path, project_root: &Path, task_id: &str, args: &[String], ) -> Result<std::process::ExitStatus> { let status = Command::new(binary_path) .args(args) .current_dir(project_root) .env( "FLOW_AI_TASK_PROJECT_ROOT", project_root.to_string_lossy().to_string(), ) .status() .with_context(|| { format!( "failed to run cached AI task '{}' binary {}", task_id, binary_path.display() ) })?; Ok(status) } fn ping() -> Result<()> { let response = send_request(&TaskdRequest::Ping, WireEncoding::Json)?; if response.ok { Ok(()) } else { bail!(response.message) } } fn send_request(request: &TaskdRequest, encoding: WireEncoding) -> Result<TaskdResponse> { let socket = socket_path(); let mut stream = UnixStream::connect(&socket) .with_context(|| format!("failed to connect to ai-taskd at {}", socket.display()))?; let body = encode_request(request, encoding)?; stream .write_all(&body) .context("failed to write ai-taskd request")?; stream .shutdown(std::net::Shutdown::Write) .context("failed to finalize ai-taskd request")?; let mut response = Vec::new(); stream .read_to_end(&mut response) .context("failed to read ai-taskd response")?; let decoded = decode_response(&response)?; Ok(decoded) } fn encode_request(request: &TaskdRequest, encoding: WireEncoding) -> Result<Vec<u8>> { match encoding { WireEncoding::Json => { serde_json::to_vec(request).context("failed to encode ai-taskd request as json") } WireEncoding::Msgpack => { let mut body = vec![MSGPACK_WIRE_PREFIX]; let encoded = rmp_serde::to_vec_named(request) .context("failed to encode ai-taskd request as msgpack")?; body.extend(encoded); Ok(body) } } } fn decode_request(payload: &[u8]) -> Result<(TaskdRequest, WireEncoding)> { match infer_encoding_from_payload(payload) { WireEncoding::Msgpack => { let request = rmp_serde::from_slice::<TaskdRequest>(&payload[1..]) .context("failed to decode ai-taskd msgpack request")?; Ok((request, WireEncoding::Msgpack)) } WireEncoding::Json => { let request = serde_json::from_slice::<TaskdRequest>(payload) .context("failed to decode ai-taskd json request")?; Ok((request, WireEncoding::Json)) } } } fn encode_response(response: &TaskdResponse, encoding: WireEncoding) -> Result<Vec<u8>> { match encoding { WireEncoding::Json => { serde_json::to_vec(response).context("failed to encode ai-taskd json response") } WireEncoding::Msgpack => { let mut body = vec![MSGPACK_WIRE_PREFIX]; let encoded = rmp_serde::to_vec_named(response) .context("failed to encode ai-taskd msgpack response")?; body.extend(encoded); Ok(body) } } } fn decode_response(payload: &[u8]) -> Result<TaskdResponse> { match infer_encoding_from_payload(payload) { WireEncoding::Msgpack => rmp_serde::from_slice::<TaskdResponse>(&payload[1..]) .context("failed to decode ai-taskd msgpack response"), WireEncoding::Json => serde_json::from_slice::<TaskdResponse>(payload) .context("failed to decode ai-taskd json response"), } } fn infer_encoding_from_payload(payload: &[u8]) -> WireEncoding { if payload.first() == Some(&MSGPACK_WIRE_PREFIX) { WireEncoding::Msgpack } else { WireEncoding::Json } } fn socket_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".flow") .join("run") .join("ai-taskd.sock") } fn pid_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".flow") .join("run") .join("ai-taskd.pid") } fn shell_quote(raw: &str) -> String { let escaped = raw.replace('\'', "'\"'\"'"); format!("'{}'", escaped) } fn write_error_response(stream: &mut UnixStream, message: String, encoding: WireEncoding) { let response = TaskdResponse { ok: false, message, exit_code: 1, stdout: String::new(), stderr: String::new(), timings: None, }; if let Ok(body) = encode_response(&response, encoding) { let _ = stream.write_all(&body); let _ = stream.flush(); } } fn timings_log_enabled() -> bool { matches!( std::env::var("FLOW_AI_TASKD_TIMINGS_LOG") .ok() .as_deref() .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), Some("1" | "true" | "yes" | "on") ) } #[cfg(test)] mod tests { use super::same_task_selector; #[test] fn selector_compare_handles_ai_prefix() { assert!(same_task_selector("ai:flow/dev-check", "flow/dev-check")); assert!(same_task_selector("flow/dev-check", "AI:FLOW/DEV-CHECK")); assert!(!same_task_selector("ai:flow/noop", "ai:flow/dev-check")); } } ================================================ FILE: src/ai_tasks.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use ignore::WalkBuilder; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoveredAiTask { pub id: String, pub selector: String, pub name: String, pub title: String, pub description: String, pub path: PathBuf, pub relative_path: String, pub tags: Vec<String>, } #[derive(Debug, Clone)] pub(crate) struct AiTaskDiscoveryArtifacts { pub tasks: Vec<DiscoveredAiTask>, pub watched_paths: Vec<PathBuf>, } #[derive(Debug, Clone, Default)] struct Metadata { title: Option<String>, description: Option<String>, tags: Vec<String>, } #[derive(Debug, Clone)] pub struct CachedTaskArtifact { pub cache_key: String, pub binary_path: PathBuf, pub rebuilt: bool, } pub fn discover_tasks(root: &Path) -> Result<Vec<DiscoveredAiTask>> { let root = if root.is_absolute() { root.to_path_buf() } else { std::env::current_dir()?.join(root) }; let root = root.canonicalize().unwrap_or(root); discover_tasks_from_root(root) } pub(crate) fn discover_tasks_from_root(root: PathBuf) -> Result<Vec<DiscoveredAiTask>> { Ok(discover_tasks_from_root_artifacts(root)?.tasks) } pub(crate) fn discover_tasks_from_root_artifacts( root: PathBuf, ) -> Result<AiTaskDiscoveryArtifacts> { let task_root = root.join(".ai").join("tasks"); let mut watched_paths = vec![root.clone()]; let ai_root = root.join(".ai"); if ai_root.exists() { watched_paths.push(ai_root); } if !task_root.exists() { return Ok(AiTaskDiscoveryArtifacts { tasks: Vec::new(), watched_paths, }); } watched_paths.push(task_root.clone()); let walker = WalkBuilder::new(&task_root) .hidden(false) .git_ignore(true) .git_global(true) .git_exclude(true) .max_depth(Some(12)) .build(); let mut out = Vec::new(); for entry in walker.flatten() { let path = entry.path(); if path.is_dir() { if !watched_paths.iter().any(|existing| existing == path) { watched_paths.push(path.to_path_buf()); } continue; } if !path.is_file() { continue; } let relative = match path.strip_prefix(&task_root) { Ok(relative) => relative, Err(_) => continue, }; let has_generated_component = relative.components().any(|component| { let s = component.as_os_str().to_string_lossy(); s == ".mooncakes" || s == "_build" }); if has_generated_component { continue; } let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or_default() .to_ascii_lowercase(); if ext != "mbt" { continue; } watched_paths.push(path.to_path_buf()); let task = parse_task(&task_root, path)?; out.push(task); } out.sort_by(|a, b| a.id.cmp(&b.id)); Ok(AiTaskDiscoveryArtifacts { tasks: out, watched_paths, }) } pub fn resolve_task_fast(root: &Path, selector: &str) -> Result<Option<DiscoveredAiTask>> { let root = if root.is_absolute() { root.to_path_buf() } else { std::env::current_dir()?.join(root) }; let root = root.canonicalize().unwrap_or(root); let task_root = root.join(".ai").join("tasks"); if !task_root.exists() { return Ok(None); } let mut needle = selector.trim().to_string(); if needle.is_empty() { return Ok(None); } if let Some(stripped) = needle.strip_prefix("ai:") { needle = stripped.trim().to_string(); } else if let Some((scope, scoped)) = parse_scoped_selector(&needle) && scope.eq_ignore_ascii_case("ai") { needle = scoped; } if needle.is_empty() { return Ok(None); } let mut candidates = Vec::new(); let base = task_root.join(&needle); if base.extension().and_then(|e| e.to_str()) == Some("mbt") { candidates.push(base); } else { candidates.push(base.with_extension("mbt")); candidates.push(base.join("main.mbt")); } if needle.contains(':') { let normalized = needle.replace(':', "/"); let norm = task_root.join(normalized); candidates.push(norm.with_extension("mbt")); candidates.push(norm.join("main.mbt")); } for candidate in candidates { if candidate.is_file() { return Ok(Some(parse_task(&task_root, &candidate)?)); } } Ok(None) } pub fn select_task<'a>( tasks: &'a [DiscoveredAiTask], selector: &str, ) -> Result<Option<&'a DiscoveredAiTask>> { let needle = selector.trim(); if needle.is_empty() { return Ok(None); } let normalized = normalize_selector(needle); let mut matches: Vec<&DiscoveredAiTask> = tasks .iter() .filter(|t| { t.id.eq_ignore_ascii_case(needle) || t.selector.eq_ignore_ascii_case(needle) || t.name.eq_ignore_ascii_case(needle) || normalize_selector(&t.selector) == normalized || normalize_selector(&t.name) == normalized }) .collect(); if let Some((scope, scoped)) = parse_scoped_selector(needle) && scope.eq_ignore_ascii_case("ai") { matches = tasks .iter() .filter(|t| { t.selector.eq_ignore_ascii_case(&scoped) || t.name.eq_ignore_ascii_case(&scoped) || normalize_selector(&t.selector) == normalize_selector(&scoped) }) .collect(); } if matches.is_empty() { return Ok(None); } if matches.len() == 1 { return Ok(Some(matches[0])); } let mut msg = String::new(); msg.push_str(&format!("AI task '{}' is ambiguous.\n", selector)); msg.push_str("Matches:\n"); for m in &matches { msg.push_str(&format!(" - {}\n", m.id)); } msg.push_str("Try one of the full selectors above."); bail!(msg); } pub fn run_task(task: &DiscoveredAiTask, project_root: &Path, args: &[String]) -> Result<()> { if !task_has_workspace(task, project_root) { return run_task_via_moon(task, project_root, args); } let runtime = std::env::var("FLOW_AI_TASK_RUNTIME") .ok() .unwrap_or_else(|| "cached".to_string()) .to_ascii_lowercase(); if runtime == "moon-run" || runtime == "moon" { return run_task_via_moon(task, project_root, args); } match run_task_cached(task, project_root, args) { Ok(()) => Ok(()), Err(cached_error) => { eprintln!( "warning: ai task cache execution failed for {} ({}), falling back to moon run", task.id, cached_error ); run_task_via_moon(task, project_root, args) } } } pub fn run_task_via_moon( task: &DiscoveredAiTask, project_root: &Path, args: &[String], ) -> Result<()> { let mut cmd = moon_run_command(task, project_root, args); let status = cmd.status().with_context(|| { format!( "failed to run AI task {} via moon ({})", task.id, task.path.display() ) })?; if !status.success() { bail!("AI task '{}' failed with status {}", task.id, status); } Ok(()) } pub fn run_task_via_moon_output( task: &DiscoveredAiTask, project_root: &Path, args: &[String], ) -> Result<Output> { let mut cmd = moon_run_command(task, project_root, args); let output = cmd.output().with_context(|| { format!( "failed to run AI task {} via moon ({})", task.id, task.path.display() ) })?; Ok(output) } pub fn build_task_cached( task: &DiscoveredAiTask, project_root: &Path, force_rebuild: bool, ) -> Result<CachedTaskArtifact> { let (workspace_dir, run_path) = resolve_moon_workspace_and_entry(task, project_root); if !workspace_dir.join("moon.mod.json").exists() && !workspace_dir.join("moon.mod").exists() { bail!( "AI task '{}' has no moon workspace root; cannot build cached binary", task.id ); } let cache_key = compute_cache_key(task, &workspace_dir, &run_path)?; let cache_dir = ai_task_cache_root()?.join(&cache_key); fs::create_dir_all(&cache_dir) .with_context(|| format!("failed to create ai task cache dir {}", cache_dir.display()))?; let binary_path = cache_dir.join("task-bin"); if binary_path.exists() && !force_rebuild { return Ok(CachedTaskArtifact { cache_key, binary_path, rebuilt: false, }); } let mut cmd = Command::new("moon"); cmd.arg("build") .arg("--target") .arg("native") .arg("--release"); if std::env::var("FLOW_AI_TASK_NO_FROZEN").is_err() { cmd.arg("--frozen"); } cmd.arg(&run_path).current_dir(&workspace_dir); let status = cmd.status().with_context(|| { format!( "failed to build AI task '{}' with moon build (workspace: {})", task.id, workspace_dir.display() ) })?; if !status.success() { bail!( "moon build failed for AI task '{}' with status {}", task.id, status ); } let built_binary = find_built_binary(&workspace_dir)?; fs::copy(&built_binary, &binary_path).with_context(|| { format!( "failed to copy built binary {} -> {}", built_binary.display(), binary_path.display() ) })?; #[cfg(unix)] { let mut perms = fs::metadata(&binary_path) .with_context(|| format!("failed to stat {}", binary_path.display()))? .permissions(); perms.set_mode(0o755); fs::set_permissions(&binary_path, perms) .with_context(|| format!("failed to chmod {}", binary_path.display()))?; } Ok(CachedTaskArtifact { cache_key, binary_path, rebuilt: true, }) } pub fn run_task_cached( task: &DiscoveredAiTask, project_root: &Path, args: &[String], ) -> Result<()> { let artifact = build_task_cached(task, project_root, false)?; let status = Command::new(&artifact.binary_path) .args(args) .current_dir(project_root) .env( "FLOW_AI_TASK_PROJECT_ROOT", project_root.to_string_lossy().to_string(), ) .status() .with_context(|| { format!( "failed to run cached AI task '{}' binary {}", task.id, artifact.binary_path.display() ) })?; if !status.success() { bail!("AI task '{}' failed with status {}", task.id, status); } Ok(()) } pub fn run_task_cached_output( task: &DiscoveredAiTask, project_root: &Path, args: &[String], ) -> Result<Output> { let artifact = build_task_cached(task, project_root, false)?; let output = Command::new(&artifact.binary_path) .args(args) .current_dir(project_root) .env( "FLOW_AI_TASK_PROJECT_ROOT", project_root.to_string_lossy().to_string(), ) .output() .with_context(|| { format!( "failed to run cached AI task '{}' binary {}", task.id, artifact.binary_path.display() ) })?; Ok(output) } pub fn default_cache_root() -> Result<PathBuf> { ai_task_cache_root() } fn moon_run_command(task: &DiscoveredAiTask, project_root: &Path, args: &[String]) -> Command { let mode = std::env::var("FLOW_AI_TASK_MODE") .ok() .unwrap_or_else(|| "dev".to_string()) .to_ascii_lowercase(); let mut cmd = Command::new("moon"); cmd.arg("run"); // Keep "dev" mode fast to iterate; allow release mode for lower runtime overhead. match mode.as_str() { "release" | "hot" | "prod" => { cmd.arg("--target").arg("native").arg("--release"); } "js" => { cmd.arg("--target").arg("js"); } _ => { cmd.arg("--target").arg("native"); } } if std::env::var("FLOW_AI_TASK_NO_FROZEN").is_err() { cmd.arg("--frozen"); } let (workspace_dir, run_path) = resolve_moon_workspace_and_entry(task, project_root); cmd.arg(&run_path); for arg in args { cmd.arg(arg); } cmd.current_dir(&workspace_dir); cmd.env( "FLOW_AI_TASK_PROJECT_ROOT", project_root.to_string_lossy().to_string(), ); cmd } pub fn task_reference(task: &DiscoveredAiTask) -> String { task.id.clone() } fn resolve_moon_workspace_and_entry( task: &DiscoveredAiTask, project_root: &Path, ) -> (PathBuf, PathBuf) { let entry_path = task.path.clone(); let start_dir = entry_path .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| project_root.to_path_buf()); if let Some(workspace) = find_moon_workspace_root(&start_dir) { if let Ok(relative) = entry_path.strip_prefix(&workspace) { return (workspace, relative.to_path_buf()); } } // Fallback to prior behavior if no moon workspace is found. (project_root.to_path_buf(), entry_path) } fn task_has_workspace(task: &DiscoveredAiTask, project_root: &Path) -> bool { let entry_path = task.path.clone(); let start_dir = entry_path .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| project_root.to_path_buf()); find_moon_workspace_root(&start_dir).is_some() } fn ai_task_cache_root() -> Result<PathBuf> { let root = dirs::cache_dir() .or_else(|| dirs::home_dir().map(|home| home.join(".cache"))) .context("failed to resolve cache root for AI tasks")? .join("flow") .join("ai-tasks"); Ok(root) } fn compute_cache_key( task: &DiscoveredAiTask, workspace_dir: &Path, run_path: &Path, ) -> Result<String> { let mut hasher = Sha256::new(); hasher.update(b"flow-ai-task-v2"); hasher.update(task.id.as_bytes()); hasher.update(task.selector.as_bytes()); hasher.update(task.path.to_string_lossy().as_bytes()); hasher.update(run_path.to_string_lossy().as_bytes()); hash_file_signature_if_exists(&mut hasher, &task.path)?; hash_file_signature_if_exists(&mut hasher, &workspace_dir.join("moon.mod.json"))?; hash_file_signature_if_exists(&mut hasher, &workspace_dir.join("moon.mod"))?; hash_file_signature_if_exists(&mut hasher, &workspace_dir.join("moon.pkg.json"))?; hash_file_signature_if_exists(&mut hasher, &workspace_dir.join("moon.pkg"))?; if let Some(version) = moon_version_for_cache_key() { hasher.update(version); } Ok(hex::encode(hasher.finalize())) } fn hash_file_signature_if_exists(hasher: &mut Sha256, path: &Path) -> Result<()> { if !path.exists() || !path.is_file() { return Ok(()); } let meta = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; hasher.update(path.to_string_lossy().as_bytes()); hasher.update(meta.len().to_le_bytes()); if let Ok(modified) = meta.modified() { let duration = modified .duration_since(UNIX_EPOCH) .unwrap_or_else(|_| Duration::from_secs(0)); hasher.update(duration.as_secs().to_le_bytes()); hasher.update(duration.subsec_nanos().to_le_bytes()); } Ok(()) } fn moon_version_for_cache_key() -> Option<Vec<u8>> { if let Ok(raw) = std::env::var("FLOW_AI_TASK_MOON_VERSION") { let trimmed = raw.trim(); if !trimmed.is_empty() { return Some(trimmed.as_bytes().to_vec()); } } let ttl_secs = std::env::var("FLOW_AI_TASK_MOON_VERSION_TTL_SECS") .ok() .and_then(|raw| raw.trim().parse::<u64>().ok()) .unwrap_or(12 * 60 * 60); let ttl = Duration::from_secs(ttl_secs); let cache_file = ai_task_cache_root().ok()?.join("moon-version.txt"); if let Ok(meta) = fs::metadata(&cache_file) && let Ok(modified) = meta.modified() && modified.elapsed().ok().is_some_and(|age| age <= ttl) && let Ok(raw) = fs::read_to_string(&cache_file) { let trimmed = raw.trim(); if !trimmed.is_empty() { return Some(trimmed.as_bytes().to_vec()); } } let out = Command::new("moon").arg("--version").output().ok()?; let mut version = String::from_utf8_lossy(&out.stdout).trim().to_string(); if version.is_empty() { version = String::from_utf8_lossy(&out.stderr).trim().to_string(); } if version.is_empty() { return None; } if let Some(parent) = cache_file.parent() { let _ = fs::create_dir_all(parent); } let _ = fs::write(&cache_file, format!("{version}\n")); Some(version.into_bytes()) } fn find_built_binary(workspace_dir: &Path) -> Result<PathBuf> { let build_dir = workspace_dir .join("_build") .join("native") .join("release") .join("build"); if !build_dir.exists() { bail!( "moon build output directory missing: {}", build_dir.display() ); } if let Some(name) = moon_mod_package_name(workspace_dir)? { let candidates = [ build_dir.join(format!("{name}.exe")), build_dir.join(name.clone()), ]; for candidate in candidates { if candidate.is_file() { return Ok(candidate); } } } let mut fallback = None; for entry in fs::read_dir(&build_dir) .with_context(|| format!("failed to read {}", build_dir.display()))? { let entry = entry?; let path = entry.path(); if !path.is_file() { continue; } let file_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or_default(); if file_name.ends_with(".exe") || is_executable(&path) { fallback = Some(path); break; } } fallback.context(format!( "failed to locate built AI task binary in {}", build_dir.display() )) } fn moon_mod_package_name(workspace_dir: &Path) -> Result<Option<String>> { let path = workspace_dir.join("moon.mod.json"); if !path.exists() { return Ok(None); } let value: serde_json::Value = serde_json::from_slice( &fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?, ) .with_context(|| format!("failed to parse {}", path.display()))?; let raw = value .get("name") .and_then(|v| v.as_str()) .unwrap_or_default() .trim(); if raw.is_empty() { return Ok(None); } Ok(Some( raw.rsplit('/').next().unwrap_or(raw).replace('.', "-"), )) } fn is_executable(path: &Path) -> bool { #[cfg(unix)] { fs::metadata(path) .map(|m| (m.permissions().mode() & 0o111) != 0) .unwrap_or(false) } #[cfg(not(unix))] { let _ = path; false } } fn find_moon_workspace_root(start: &Path) -> Option<PathBuf> { let mut current = Some(start); while let Some(dir) = current { if dir.join("moon.mod.json").exists() || dir.join("moon.mod").exists() { return Some(dir.to_path_buf()); } current = dir.parent(); } None } fn parse_task(task_root: &Path, path: &Path) -> Result<DiscoveredAiTask> { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read AI task {}", path.display()))?; let metadata = parse_metadata(&content); let relative = path.strip_prefix(task_root).unwrap_or(path); let mut selector = relative .with_extension("") .to_string_lossy() .replace('\\', "/"); if let Some(trimmed) = selector.strip_suffix("/main") { selector = trimmed.to_string(); } let mut name = relative .file_stem() .and_then(|s| s.to_str()) .unwrap_or("task") .to_string(); if name == "main" { if let Some(parent_name) = relative .parent() .and_then(|p| p.file_name()) .and_then(|s| s.to_str()) { name = parent_name.to_string(); } } if selector.is_empty() { selector = name.clone(); } let title = metadata.title.unwrap_or_else(|| name.replace('-', " ")); let description = metadata.description.unwrap_or_default(); let id = format!("ai:{}", selector); Ok(DiscoveredAiTask { id, selector, name, title, description, path: path.to_path_buf(), relative_path: relative.to_string_lossy().replace('\\', "/"), tags: metadata.tags, }) } fn parse_metadata(content: &str) -> Metadata { let mut md = Metadata::default(); for raw in content.lines() { let line = raw.trim(); if line.is_empty() { continue; } let Some(comment) = line.strip_prefix("//") else { break; }; let comment = comment.trim(); let Some((key, value)) = comment.split_once(':') else { continue; }; let key = key.trim().to_ascii_lowercase(); let value = value.trim(); if key == "title" { md.title = Some(strip_quotes(value)); } else if key == "description" { md.description = Some(strip_quotes(value)); } else if key == "tags" { md.tags = parse_tags(value); } } md } fn parse_tags(value: &str) -> Vec<String> { let v = strip_quotes(value); let trimmed = v.trim(); let inner = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { &trimmed[1..trimmed.len() - 1] } else { trimmed }; inner .split(',') .map(strip_quotes) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } fn strip_quotes(value: &str) -> String { let trimmed = value.trim(); if trimmed.len() >= 2 { let bytes = trimmed.as_bytes(); if (bytes[0] == b'"' && bytes[trimmed.len() - 1] == b'"') || (bytes[0] == b'\'' && bytes[trimmed.len() - 1] == b'\'') { return trimmed[1..trimmed.len() - 1].to_string(); } } trimmed.to_string() } fn parse_scoped_selector(selector: &str) -> Option<(String, String)> { let trimmed = selector.trim(); if let Some((scope, task)) = trimmed.split_once(':') { let scope = scope.trim(); let task = task.trim(); if !scope.is_empty() && !task.is_empty() { return Some((scope.to_string(), task.to_string())); } } if let Some((scope, task)) = trimmed.split_once('/') { let scope = scope.trim(); let task = task.trim(); if !scope.is_empty() && !task.is_empty() { return Some((scope.to_string(), task.to_string())); } } None } fn normalize_selector(raw: &str) -> String { raw.chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '-' } }) .collect::<String>() .trim_matches('-') .to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn parses_metadata_comments() { let text = "// title: Fast Open\n\ // description: Open app quickly\n\ // tags: [moonbit, fast]\n\ \n\ fn main {}\n"; let md = parse_metadata(text); assert_eq!(md.title.as_deref(), Some("Fast Open")); assert_eq!(md.description.as_deref(), Some("Open app quickly")); assert_eq!(md.tags, vec!["moonbit".to_string(), "fast".to_string()]); } } ================================================ FILE: src/ai_test.rs ================================================ use std::fs; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, bail}; use crate::cli::AiTestNewOpts; pub fn run(opts: AiTestNewOpts) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; let project_root = find_project_root(&cwd)?; let base_dir = project_root.join(normalize_relative_dir(&opts.dir)?); let rel_file = normalize_test_name(&opts.name, opts.spec)?; let full_path = base_dir.join(&rel_file); if full_path.exists() && !opts.force { bail!( "scratch test already exists: {} (use --force to overwrite)", full_path.display() ); } if let Some(parent) = full_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let title = rel_file .to_string_lossy() .replace('\\', "/") .trim_end_matches(".ts") .trim_end_matches(".tsx") .trim_end_matches(".js") .trim_end_matches(".jsx") .trim_end_matches(".mjs") .trim_end_matches(".cjs") .to_string(); let template = format!( "import {{ describe, it }} from \"bun:test\";\n\n\ describe(\"{}\", () => {{\n\ it.todo(\"add assertions\");\n\ }});\n", title ); fs::write(&full_path, template) .with_context(|| format!("failed to write {}", full_path.display()))?; let relative_to_project = full_path .strip_prefix(&project_root) .unwrap_or(&full_path) .to_path_buf(); println!("Created scratch test: {}", relative_to_project.display()); println!("Run: f ai-test"); println!("Watch: f ai-test-watch"); Ok(()) } fn find_project_root(start: &Path) -> Result<PathBuf> { let mut current = start.to_path_buf(); loop { if current.join("flow.toml").exists() { return Ok(current); } if !current.pop() { bail!("no flow.toml found in current directory or parents"); } } } fn normalize_relative_dir(raw: &str) -> Result<PathBuf> { let path = Path::new(raw); if path.is_absolute() { bail!("--dir must be relative to project root"); } let mut out = PathBuf::new(); for comp in path.components() { match comp { Component::Normal(s) => out.push(s), Component::CurDir => {} Component::ParentDir | Component::RootDir | Component::Prefix(_) => { bail!("--dir must not contain parent traversal") } } } if out.as_os_str().is_empty() { bail!("--dir cannot be empty"); } Ok(out) } fn normalize_test_name(raw: &str, use_spec: bool) -> Result<PathBuf> { let mut segments: Vec<String> = raw .replace('\\', "/") .split('/') .filter(|s| !s.trim().is_empty()) .map(sanitize_segment) .filter(|s| !s.is_empty()) .collect(); if segments.is_empty() { bail!("name must contain at least one non-empty path segment"); } let file = segments.pop().expect("checked non-empty"); let file = normalize_file_component(&file, use_spec); let mut out = PathBuf::new(); for segment in segments { out.push(segment); } out.push(file); Ok(out) } fn sanitize_segment(raw: &str) -> String { let mut out = String::new(); let mut prev_dash = false; for ch in raw.chars() { let keep = ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'; let next = if keep { ch } else { '-' }; if next == '-' { if prev_dash { continue; } prev_dash = true; } else { prev_dash = false; } out.push(next); } out.trim_matches(&['-', '.'][..]).to_string() } fn normalize_file_component(file: &str, use_spec: bool) -> String { const KNOWN_EXTS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs", "cjs"]; let suffix = if use_spec { "spec" } else { "test" }; if let Some((stem, ext)) = file.rsplit_once('.') { let ext_lower = ext.to_ascii_lowercase(); if KNOWN_EXTS.contains(&ext_lower.as_str()) { if stem.ends_with(".test") || stem.ends_with(".spec") { return file.to_string(); } return format!("{stem}.{suffix}.{ext_lower}"); } } if file.contains(".test.") || file.contains(".spec.") { return file.to_string(); } format!("{file}.{suffix}.ts") } #[cfg(test)] mod tests { use super::*; #[test] fn appends_test_suffix_for_plain_name() { let path = normalize_test_name("auth-login", false).unwrap(); assert_eq!(path, PathBuf::from("auth-login.test.ts")); } #[test] fn preserves_existing_test_suffix() { let path = normalize_test_name("chat/loading.test.ts", true).unwrap(); assert_eq!(path, PathBuf::from("chat/loading.test.ts")); } #[test] fn adds_spec_before_extension() { let path = normalize_test_name("chat/loading.tsx", true).unwrap(); assert_eq!(path, PathBuf::from("chat/loading.spec.tsx")); } } ================================================ FILE: src/analytics.rs ================================================ use anyhow::Result; use crate::cli::{AnalyticsAction, AnalyticsCommand}; use crate::usage::{self, AnalyticsConsent}; pub fn run(cmd: AnalyticsCommand) -> Result<()> { match cmd.action.unwrap_or(AnalyticsAction::Status) { AnalyticsAction::Status => { let status = usage::status()?; println!("consent: {:?}", status.consent); println!("effective_enabled: {}", status.effective_enabled); println!("install_id: {}", status.install_id); println!("endpoint: {}", status.endpoint); println!("queue_path: {}", status.queue_path.display()); println!("queued_events: {}", status.queued_events); } AnalyticsAction::Enable => { usage::set_consent(AnalyticsConsent::Enabled)?; println!("Anonymous usage tracking enabled."); } AnalyticsAction::Disable => { usage::set_consent(AnalyticsConsent::Disabled)?; println!("Anonymous usage tracking disabled."); } AnalyticsAction::Export => { let content = usage::export_queue()?; if content.trim().is_empty() { println!("(no queued analytics events)"); } else { print!("{content}"); } } AnalyticsAction::Purge => { usage::purge_queue()?; println!("Purged queued analytics events."); } } Ok(()) } ================================================ FILE: src/archive.rs ================================================ use std::fs; use std::path::Path; use anyhow::{Context, Result, bail}; use chrono::Local; use crate::ai_context; use crate::cli::ArchiveOpts; pub fn run(opts: ArchiveOpts) -> Result<()> { let root = ai_context::find_project_root().ok_or_else(|| anyhow::anyhow!("project root not found"))?; let root = fs::canonicalize(&root).unwrap_or(root); let project_name = root .file_name() .and_then(|name| name.to_str()) .filter(|name| !name.trim().is_empty()) .unwrap_or("project"); let message = opts.message.trim(); if message.is_empty() { bail!("archive message cannot be empty"); } let slug = sanitize_segment(message); if slug.is_empty() { bail!("archive message must include at least one letter or number"); } let home = dirs::home_dir() .ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))? .to_path_buf(); let archive_root = home.join("archive").join("code"); fs::create_dir_all(&archive_root).with_context(|| { format!( "failed to create archive directory {}", archive_root.display() ) })?; let code_root = fs::canonicalize(home.join("code")).unwrap_or_else(|_| home.join("code")); let rel_path = root.strip_prefix(&code_root).ok(); let (dest_parent, base_project) = if let Some(rel) = rel_path { let parent = rel .parent() .map(|p| archive_root.join(p)) .unwrap_or_else(|| archive_root.clone()); let name = rel .file_name() .and_then(|name| name.to_str()) .filter(|name| !name.trim().is_empty()) .unwrap_or(project_name) .to_string(); (parent, name) } else { (archive_root.clone(), project_name.to_string()) }; fs::create_dir_all(&dest_parent).with_context(|| { format!( "failed to create archive directory {}", dest_parent.display() ) })?; let date_suffix = Local::now() .format("%b-%d-%y") .to_string() .to_ascii_lowercase(); let base_name = format!("{}-{}-{}", base_project, slug, date_suffix); let mut dest = dest_parent.join(&base_name); if dest.exists() { let suffix = Local::now().format("%Y%m%d-%H%M%S"); dest = dest_parent.join(format!("{}-{}", base_name, suffix)); } copy_dir_all(&root, &dest, &ArchiveFilter::default())?; println!("Archived {} -> {}", root.display(), dest.display()); Ok(()) } #[derive(Default)] struct ArchiveFilter { skip_names: Vec<&'static str>, } impl ArchiveFilter { fn default() -> Self { Self { skip_names: vec![ ".jj", "node_modules", "target", "dist", "build", ".next", ".turbo", ".cache", ], } } fn should_skip(&self, path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .map(|name| self.skip_names.contains(&name)) .unwrap_or(false) } } fn copy_dir_all(from: &Path, to: &Path, filter: &ArchiveFilter) -> Result<()> { fs::create_dir_all(to).with_context(|| format!("failed to create {}", to.display()))?; for entry in fs::read_dir(from).with_context(|| format!("failed to read {}", from.display()))? { let entry = entry?; let path = entry.path(); if filter.should_skip(&path) { continue; } let file_type = entry.file_type()?; let target = to.join(entry.file_name()); if target.exists() { bail!("Refusing to overwrite {}", target.display()); } if file_type.is_dir() { copy_dir_all(&path, &target, filter)?; } else if file_type.is_file() { fs::copy(&path, &target) .with_context(|| format!("failed to copy {}", path.display()))?; } else if file_type.is_symlink() { let link_target = fs::read_link(&path) .with_context(|| format!("failed to read link {}", path.display()))?; copy_symlink(&link_target, &target)?; } } Ok(()) } fn copy_symlink(target: &Path, dest: &Path) -> Result<()> { #[cfg(unix)] { std::os::unix::fs::symlink(target, dest) .with_context(|| format!("failed to create symlink {}", dest.display()))?; return Ok(()); } #[cfg(not(unix))] { let metadata = fs::metadata(target).with_context(|| format!("failed to read {}", target.display()))?; if metadata.is_dir() { copy_dir_all(target, dest, &ArchiveFilter::default())?; } else { fs::copy(target, dest) .with_context(|| format!("failed to copy {}", target.display()))?; } Ok(()) } } fn sanitize_segment(value: &str) -> String { let mut out = String::new(); let mut prev_dash = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); prev_dash = false; } else if !prev_dash { out.push('-'); prev_dash = true; } } out.trim_matches('-').to_string() } ================================================ FILE: src/ask.rs ================================================ //! Ask the AI server to suggest a task or flow command. use std::collections::HashSet; use std::path::PathBuf; use anyhow::{Result, bail}; use clap::CommandFactory; use crate::ai_server; use crate::cli::Cli; use crate::discover::{self, DiscoveredTask}; /// Options for the ask command. #[derive(Debug, Clone)] pub struct AskOpts { /// The user's query as separate arguments (preserves quoting from shell). pub args: Vec<String>, /// AI server model to use. pub model: Option<String>, /// AI server URL override. pub url: Option<String>, } enum AskSelection { Task { name: String }, Command { command: String }, } struct FlowCommand { name: String, aliases: Vec<String>, about: Option<String>, } /// Ask the AI server for a suggested task or command. pub fn run(opts: AskOpts) -> Result<()> { let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let discovery = discover::discover_tasks(&root)?; run_with_tasks(opts, discovery.tasks) } fn run_with_tasks(opts: AskOpts, tasks: Vec<DiscoveredTask>) -> Result<()> { let query_display = opts.args.join(" "); if let Some(direct) = try_direct_match(&opts.args, &tasks) { let matched = find_task(&direct.task_name, &tasks)?; print_task_suggestion(matched, &direct.args); return Ok(()); } if is_cli_subcommand(&opts.args) { let command = format!("f {}", opts.args.join(" ")); print_command_suggestion(&command); return Ok(()); } let commands = flow_command_candidates(); let valid_subcommands = valid_subcommand_set(&commands); let prompt = build_prompt(&query_display, &tasks, &commands); let response = ai_server::quick_prompt(&prompt, opts.model.as_deref(), opts.url.as_deref(), None)?; let selection = parse_ask_response(&response, &tasks, &valid_subcommands)?; match selection { AskSelection::Task { name } => { let matched = find_task(&name, &tasks)?; print_task_suggestion(matched, &[]); } AskSelection::Command { command } => { print_command_suggestion(&command); } } Ok(()) } fn print_task_suggestion(task: &DiscoveredTask, args: &[String]) { let command = if args.is_empty() { format!("f {}", task.task.name) } else { format!("f {} {}", task.task.name, args.join(" ")) }; println!("Suggested command:"); println!("{}", command); let mut detail = format!("Matched task: {}", task.task.name); if !task.relative_dir.is_empty() { detail.push_str(&format!(" ({})", task.relative_dir)); } detail.push_str(&format!(" - {}", task.task.command)); println!("{}", detail); } fn print_command_suggestion(command: &str) { println!("Suggested command:"); println!("{}", command.trim()); } fn find_task<'a>(name: &str, tasks: &'a [DiscoveredTask]) -> Result<&'a DiscoveredTask> { tasks .iter() .find(|t| t.task.name.eq_ignore_ascii_case(name)) .ok_or_else(|| anyhow::anyhow!("AI returned unknown task: {}", name)) } fn flow_command_candidates() -> Vec<FlowCommand> { let mut commands = Vec::new(); let cmd = Cli::command(); for sub in cmd.get_subcommands() { let name = sub.get_name().to_string(); let about = sub .get_about() .map(|s| s.to_string()) .filter(|s| !s.is_empty()); let aliases = sub.get_all_aliases().map(|a| a.to_string()).collect(); commands.push(FlowCommand { name, aliases, about, }); } commands.push(FlowCommand { name: "tasks list".to_string(), aliases: Vec::new(), about: Some("List tasks from flow.toml.".to_string()), }); commands.sort_by(|a, b| a.name.cmp(&b.name)); commands } fn valid_subcommand_set(commands: &[FlowCommand]) -> HashSet<String> { let mut set = HashSet::new(); for cmd in commands { let name = cmd.name.split_whitespace().next().unwrap_or("").to_string(); if !name.is_empty() { set.insert(name.to_ascii_lowercase()); } for alias in &cmd.aliases { set.insert(alias.to_ascii_lowercase()); } } set.insert("help".to_string()); set.insert("-h".to_string()); set.insert("--help".to_string()); set } fn build_prompt(query: &str, tasks: &[DiscoveredTask], commands: &[FlowCommand]) -> String { let mut prompt = String::new(); prompt.push_str("You are a Flow CLI assistant.\n"); prompt.push_str("Choose the best command for the user to run.\n"); prompt.push_str("Respond with ONE line in one of these formats only:\n"); prompt.push_str("task:<task_name>\n"); prompt.push_str("cmd:f <flow command>\n\n"); if tasks.is_empty() { prompt.push_str("No flow.toml tasks were discovered.\n"); } else { prompt.push_str("Available tasks:\n"); for task in tasks { let location = if task.relative_dir.is_empty() { String::new() } else { format!(" (in {})", task.relative_dir) }; let desc = task .task .description .as_deref() .unwrap_or(&task.task.command); prompt.push_str(&format!("- {}{}: {}\n", task.task.name, location, desc)); } } prompt.push_str("\nFlow CLI commands:\n"); for cmd in commands { let mut line = format!("- f {}", cmd.name); if let Some(about) = &cmd.about { if !about.trim().is_empty() { line.push_str(&format!(": {}", about.trim())); } } prompt.push_str(&format!("{}\n", line)); } prompt.push_str(&format!("\nUser query: {}\n", query)); prompt.push_str("Answer:"); prompt } fn parse_ask_response( response: &str, tasks: &[DiscoveredTask], valid_subcommands: &HashSet<String>, ) -> Result<AskSelection> { let cleaned = response.trim().trim_matches('`').trim(); if cleaned.is_empty() { bail!("AI returned an empty response."); } if let Some(selection) = parse_structured_line(cleaned, tasks, valid_subcommands)? { return Ok(selection); } // Some models emit reasoning wrappers (e.g. <think>...</think>) before the // final machine-readable answer. Scan lines and parse the first valid one. for line in cleaned.lines() { let candidate = line.trim(); if candidate.is_empty() { continue; } if let Some(selection) = parse_structured_line(candidate, tasks, valid_subcommands)? { return Ok(selection); } } if cleaned.starts_with("f ") || cleaned.starts_with("flow ") { let command = normalize_command(cleaned, valid_subcommands)?; return Ok(AskSelection::Command { command }); } if let Ok(task_name) = extract_task_name(cleaned, tasks) { return Ok(AskSelection::Task { name: task_name }); } if is_command_like(cleaned, valid_subcommands) { let command = normalize_command(cleaned, valid_subcommands)?; return Ok(AskSelection::Command { command }); } bail!("Could not parse AI response: '{}'", cleaned); } fn parse_structured_line( raw: &str, tasks: &[DiscoveredTask], valid_subcommands: &HashSet<String>, ) -> Result<Option<AskSelection>> { if let Some(rest) = raw.strip_prefix("task:") { let task_name = extract_task_name(rest.trim(), tasks)?; return Ok(Some(AskSelection::Task { name: task_name })); } if let Some(rest) = raw.strip_prefix("cmd:") { let command = normalize_command(rest, valid_subcommands)?; return Ok(Some(AskSelection::Command { command })); } if let Some(rest) = raw.strip_prefix("command:") { let command = normalize_command(rest, valid_subcommands)?; return Ok(Some(AskSelection::Command { command })); } Ok(None) } fn normalize_command(raw: &str, valid_subcommands: &HashSet<String>) -> Result<String> { let mut cmd = raw.trim().trim_matches('`').trim().to_string(); if cmd.starts_with("cmd:") { cmd = cmd.trim_start_matches("cmd:").trim().to_string(); } else if cmd.starts_with("command:") { cmd = cmd.trim_start_matches("command:").trim().to_string(); } if cmd.starts_with("flow ") { cmd = format!("f {}", cmd.trim_start_matches("flow ").trim()); } else if !cmd.starts_with("f ") { cmd = format!("f {}", cmd); } let tokens = shell_words::split(&cmd) .unwrap_or_else(|_| cmd.split_whitespace().map(|s| s.to_string()).collect()); if tokens.len() < 2 { bail!("Command '{}' is incomplete.", cmd); } let sub = tokens[1].to_ascii_lowercase(); if !valid_subcommands.contains(&sub) { bail!("AI returned unknown command '{}'.", cmd); } Ok(cmd) } fn is_command_like(raw: &str, valid_subcommands: &HashSet<String>) -> bool { let first = raw .split_whitespace() .next() .unwrap_or("") .trim() .to_ascii_lowercase(); if first.is_empty() { return false; } valid_subcommands.contains(&first) } fn cli_subcommands() -> Vec<String> { let mut names = Vec::new(); let cmd = Cli::command(); for sub in cmd.get_subcommands() { names.push(sub.get_name().to_string()); for alias in sub.get_all_aliases() { names.push(alias.to_string()); } } names } fn is_cli_subcommand(args: &[String]) -> bool { let Some(first) = args.first() else { return false; }; let first_lower = first.to_ascii_lowercase(); cli_subcommands() .iter() .any(|cmd| cmd.eq_ignore_ascii_case(&first_lower)) } /// Normalize a string by removing hyphens, underscores, and lowercasing. fn normalize_name(s: &str) -> String { s.chars() .filter(|c| *c != '-' && *c != '_') .collect::<String>() .to_ascii_lowercase() } /// Result of a direct match attempt - includes task name and any extra args. struct DirectMatchResult { task_name: String, args: Vec<String>, } /// Try to match query directly to a task name, shortcut, or abbreviation. fn try_direct_match(args: &[String], tasks: &[DiscoveredTask]) -> Option<DirectMatchResult> { if args.is_empty() { return None; } let first = args[0].trim(); let rest: Vec<String> = args[1..].to_vec(); if let Some(task) = tasks .iter() .find(|t| t.task.name.eq_ignore_ascii_case(first)) { return Some(DirectMatchResult { task_name: task.task.name.clone(), args: rest, }); } if let Some(task) = tasks.iter().find(|t| { t.task .shortcuts .iter() .any(|s| s.eq_ignore_ascii_case(first)) }) { return Some(DirectMatchResult { task_name: task.task.name.clone(), args: rest, }); } let normalized_query = normalize_name(first); let mut normalized_matches: Vec<_> = tasks .iter() .filter(|t| normalize_name(&t.task.name) == normalized_query) .collect(); if normalized_matches.len() == 1 { return Some(DirectMatchResult { task_name: normalized_matches.remove(0).task.name.clone(), args: rest, }); } let needle = first.to_ascii_lowercase(); if needle.len() >= 2 { let mut matches = tasks.iter().filter(|t| { generate_abbreviation(&t.task.name) .map(|abbr| abbr == needle) .unwrap_or(false) }); if let Some(first_match) = matches.next() { if matches.next().is_none() { return Some(DirectMatchResult { task_name: first_match.task.name.clone(), args: rest, }); } } } if needle.len() >= 2 { let mut prefix_matches: Vec<_> = tasks .iter() .filter(|t| t.task.name.to_ascii_lowercase().starts_with(&needle)) .collect(); if prefix_matches.len() == 1 { return Some(DirectMatchResult { task_name: prefix_matches.remove(0).task.name.clone(), args: rest, }); } } None } fn generate_abbreviation(name: &str) -> Option<String> { let mut abbr = String::new(); let mut new_segment = true; for ch in name.chars() { if ch.is_ascii_alphanumeric() { if new_segment { abbr.push(ch.to_ascii_lowercase()); new_segment = false; } } else { new_segment = true; } } if abbr.len() >= 2 { Some(abbr) } else { None } } fn extract_task_name(response: &str, tasks: &[DiscoveredTask]) -> Result<String> { let response = response.trim(); for task in tasks { if task.task.name.eq_ignore_ascii_case(response) { return Ok(task.task.name.clone()); } } for task in tasks { if response .to_lowercase() .contains(&task.task.name.to_lowercase()) { return Ok(task.task.name.clone()); } } let cleaned = response .trim_start_matches(|c: char| !c.is_alphanumeric()) .trim_end_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') .to_string(); for task in tasks { if task.task.name.eq_ignore_ascii_case(&cleaned) { return Ok(task.task.name.clone()); } } bail!("Could not parse task name from AI response: '{}'", response) } #[cfg(test)] mod tests { use super::*; use crate::config::TaskConfig; fn make_discovered(name: &str) -> DiscoveredTask { DiscoveredTask { task: TaskConfig { name: name.to_string(), command: format!("echo {}", name), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, config_path: PathBuf::from("flow.toml"), relative_dir: String::new(), depth: 0, scope: "root".to_string(), scope_aliases: vec!["root".to_string()], } } #[test] fn parse_task_response() { let tasks = vec![make_discovered("build")]; let mut cmds = HashSet::new(); cmds.insert("tasks".to_string()); let parsed = parse_ask_response("task:build", &tasks, &cmds).unwrap(); match parsed { AskSelection::Task { name } => assert_eq!(name, "build"), _ => panic!("expected task"), } } #[test] fn parse_command_response() { let tasks = vec![make_discovered("build")]; let mut cmds = HashSet::new(); cmds.insert("tasks".to_string()); let parsed = parse_ask_response("cmd:f tasks list", &tasks, &cmds).unwrap(); match parsed { AskSelection::Command { command } => assert_eq!(command, "f tasks list"), _ => panic!("expected command"), } } } ================================================ FILE: src/auth.rs ================================================ use std::thread::sleep; use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; use reqwest::blocking::Client; use serde::Deserialize; use crate::cli::AuthOpts; use crate::env; #[derive(Debug, Deserialize)] struct DeviceStartResponse { device_code: String, user_code: String, verification_url: String, expires_in: u64, #[serde(default = "default_poll_interval")] interval: u64, } fn default_poll_interval() -> u64 { 2 } #[derive(Debug, Deserialize)] struct DevicePollResponse { status: String, token: Option<String>, } pub fn run(opts: AuthOpts) -> Result<()> { login(opts.api_url) } fn login(api_url_override: Option<String>) -> Result<()> { let api_url = api_url_override .or_else(|| env::load_ai_api_url().ok()) .unwrap_or_else(|| "https://myflow.sh".to_string()); let api_url = api_url.trim().trim_end_matches('/').to_string(); let client = Client::builder() .timeout(Duration::from_secs(30)) .build() .context("failed to create HTTP client for auth")?; let start_url = format!("{}/api/auth/cli/start", api_url); let response = client .post(&start_url) .json(&serde_json::json!({"client": "flow"})) .send() .context("failed to start device auth")?; if !response.status().is_success() { bail!("device auth start failed: HTTP {}", response.status()); } let payload: DeviceStartResponse = response .json() .context("failed to parse device auth response")?; println!("\nFlow auth with myflow"); println!("───────────────────────"); println!("Code: {}", payload.user_code); println!("Open: {}\n", payload.verification_url); open_in_browser(&payload.verification_url); let expires_at = Instant::now() + Duration::from_secs(payload.expires_in); let poll_url = format!("{}/api/auth/cli/poll", api_url); println!("Waiting for approval..."); while Instant::now() < expires_at { sleep(Duration::from_secs(payload.interval.max(1))); let poll_response = client .post(&poll_url) .json(&serde_json::json!({"device_code": payload.device_code})) .send() .context("failed to poll device auth")?; if !poll_response.status().is_success() { bail!("device auth poll failed: HTTP {}", poll_response.status()); } let poll: DevicePollResponse = poll_response .json() .context("failed to parse device auth poll response")?; match poll.status.as_str() { "approved" => { let token = poll .token .ok_or_else(|| anyhow!("device auth approved without token"))?; env::save_ai_auth_token(token, Some(api_url.clone()))?; println!("✓ Auth complete. You're ready to use Flow AI."); return Ok(()); } "pending" => continue, "expired" => bail!("device code expired. Run `f auth` again."), "invalid" => bail!("device code invalid. Run `f auth` again."), other => bail!("unexpected auth status: {}", other), } } bail!("device code expired. Run `f auth` again.") } fn open_in_browser(url: &str) { #[cfg(target_os = "macos")] { let _ = std::process::Command::new("open").arg(url).status(); } #[cfg(target_os = "linux")] { let _ = std::process::Command::new("xdg-open").arg(url).status(); } #[cfg(not(any(target_os = "macos", target_os = "linux")))] println!("Open this URL in your browser: {}", url); } ================================================ FILE: src/base_tool.rs ================================================ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result}; pub fn resolve_bin() -> Option<PathBuf> { if let Ok(value) = std::env::var("FLOW_BASE_BIN") { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } // Prefer a more specific name, but fall back to the current base repo binary name. for name in ["base", "db"] { if let Ok(path) = which::which(name) { return Some(path); } } None } pub fn run_inherit_stdio(bin: &Path, args: &[String]) -> Result<()> { let status = Command::new(bin) .args(args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .with_context(|| format!("failed to run {} {}", bin.display(), args.join(" ")))?; if !status.success() { anyhow::bail!("{} exited with {}", bin.display(), status); } Ok(()) } pub fn run_with_stdin(bin: &Path, args: &[String], stdin: &str) -> Result<()> { let mut child = Command::new(bin) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::null()) // This path is currently only used for best-effort "task run" ingestion. // If the user has some other `base` binary on PATH (or an older one), // it may print usage/errors like "unrecognized subcommand 'ingest'". // We intentionally silence stderr to avoid confusing noise during normal runs. .stderr(Stdio::null()) .spawn() .with_context(|| format!("failed to spawn {} {}", bin.display(), args.join(" ")))?; { use std::io::Write; let child_stdin = child.stdin.as_mut().context("failed to open stdin")?; child_stdin.write_all(stdin.as_bytes())?; } let status = child.wait()?; if !status.success() { anyhow::bail!("{} exited with {}", bin.display(), status); } Ok(()) } ================================================ FILE: src/bin/ai_taskd_client.rs ================================================ use std::env; use std::io::{self, Read, Write}; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::process; use serde::{Deserialize, Serialize}; const MSGPACK_WIRE_PREFIX: u8 = 0xFF; #[derive(Debug, Clone, Copy)] enum WireProtocol { Json, Msgpack, } #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum TaskdRequest { Run { project_root: String, selector: String, args: Vec<String>, no_cache: bool, capture_output: bool, include_timings: bool, suggested_task: Option<String>, override_reason: Option<String>, }, } #[derive(Debug, Deserialize)] struct TaskdResponse { ok: bool, message: String, exit_code: i32, stdout: String, stderr: String, timings: Option<RequestTimings>, } #[derive(Debug, Deserialize)] struct RequestTimings { resolve_selector_us: u64, run_task_us: u64, total_us: u64, used_fast_selector: bool, used_cache: bool, } fn main() { match run() { Ok(code) => process::exit(code), Err(msg) => { eprintln!("{msg}"); process::exit(1); } } } fn run() -> Result<i32, String> { let args: Vec<String> = env::args().skip(1).collect(); if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") { print_help(); return Ok(0); } let mut root = env::current_dir() .map_err(|e| format!("failed to resolve cwd: {e}"))? .to_string_lossy() .to_string(); let mut no_cache = false; let mut capture_output = false; let mut include_timings = false; let mut batch_stdin = false; let mut protocol = WireProtocol::Msgpack; let mut socket = default_socket_path(); let mut idx = 0usize; while idx < args.len() { let arg = args[idx].clone(); match arg.as_str() { "--root" => { idx += 1; let value = args.get(idx).ok_or("--root requires a value")?; root = value.clone(); } "--socket" => { idx += 1; let value = args.get(idx).ok_or("--socket requires a value")?; socket = PathBuf::from(value); } "--protocol" => { idx += 1; let value = args .get(idx) .ok_or("--protocol requires a value (json|msgpack)")?; protocol = match value.trim().to_ascii_lowercase().as_str() { "json" => WireProtocol::Json, "msgpack" | "mp" => WireProtocol::Msgpack, other => return Err(format!("unsupported protocol '{other}'")), }; } "--no-cache" => { no_cache = true; } "--capture-output" => { capture_output = true; } "--timings" => { include_timings = true; } "--batch-stdin" => { batch_stdin = true; } _ => break, } idx += 1; } if batch_stdin { return run_batch( &socket, protocol, &root, no_cache, capture_output, include_timings, ); } if idx >= args.len() { return Err("missing selector".to_string()); } let selector = args[idx].clone(); let trailing = if idx + 1 < args.len() { if args[idx + 1] == "--" { args[(idx + 2)..].to_vec() } else { args[(idx + 1)..].to_vec() } } else { Vec::new() }; let response = run_once( &socket, protocol, &root, &selector, &trailing, no_cache, capture_output, include_timings, )?; print_response(&response, include_timings, &selector); if response.ok { return Ok(0); } eprintln!("{}", response.message); Ok(if response.exit_code == 0 { 1 } else { response.exit_code }) } fn run_batch( socket: &PathBuf, protocol: WireProtocol, root: &str, no_cache: bool, capture_output: bool, include_timings: bool, ) -> Result<i32, String> { let mut input = String::new(); io::stdin() .read_to_string(&mut input) .map_err(|e| format!("failed to read stdin: {e}"))?; let mut any_failure = false; for (line_no, raw) in input.lines().enumerate() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } let tokens = shell_words::split(line) .map_err(|e| format!("batch parse error at line {}: {e}", line_no + 1))?; if tokens.is_empty() { continue; } let selector = tokens[0].clone(); let args = if tokens.len() > 1 { tokens[1..].to_vec() } else { Vec::new() }; let response = run_once( socket, protocol, root, &selector, &args, no_cache, capture_output, include_timings, )?; print_response(&response, include_timings, &selector); if !response.ok { any_failure = true; eprintln!("[batch][{}] {}", selector, response.message); } } Ok(if any_failure { 1 } else { 0 }) } #[allow(clippy::too_many_arguments)] fn run_once( socket: &PathBuf, protocol: WireProtocol, root: &str, selector: &str, args: &[String], no_cache: bool, capture_output: bool, include_timings: bool, ) -> Result<TaskdResponse, String> { let req = TaskdRequest::Run { project_root: root.to_string(), selector: selector.to_string(), args: args.to_vec(), no_cache, capture_output, include_timings, suggested_task: read_optional_env("FLOW_ROUTER_SUGGESTED_TASK"), override_reason: read_optional_env("FLOW_ROUTER_OVERRIDE_REASON"), }; send_request(socket, protocol, &req) } fn send_request( socket: &PathBuf, protocol: WireProtocol, request: &TaskdRequest, ) -> Result<TaskdResponse, String> { let req_bytes = encode_request(request, protocol)?; let mut stream = UnixStream::connect(socket) .map_err(|e| format!("failed to connect to {}: {e}", socket.display()))?; stream .write_all(&req_bytes) .map_err(|e| format!("failed to write request: {e}"))?; stream .shutdown(std::net::Shutdown::Write) .map_err(|e| format!("failed to finalize request: {e}"))?; let mut body = Vec::new(); stream .read_to_end(&mut body) .map_err(|e| format!("failed to read response: {e}"))?; decode_response(&body) } fn encode_request(request: &TaskdRequest, protocol: WireProtocol) -> Result<Vec<u8>, String> { match protocol { WireProtocol::Json => { serde_json::to_vec(request).map_err(|e| format!("failed to encode json request: {e}")) } WireProtocol::Msgpack => { let mut out = vec![MSGPACK_WIRE_PREFIX]; let encoded = rmp_serde::to_vec_named(request) .map_err(|e| format!("failed to encode msgpack request: {e}"))?; out.extend(encoded); Ok(out) } } } fn decode_response(payload: &[u8]) -> Result<TaskdResponse, String> { if payload.first() == Some(&MSGPACK_WIRE_PREFIX) { return rmp_serde::from_slice::<TaskdResponse>(&payload[1..]) .map_err(|e| format!("failed to decode msgpack response: {e}")); } serde_json::from_slice::<TaskdResponse>(payload) .map_err(|e| format!("failed to decode json response: {e}")) } fn print_response(response: &TaskdResponse, include_timings: bool, selector: &str) { if !response.stdout.is_empty() { print!("{}", response.stdout); } if !response.stderr.is_empty() { eprint!("{}", response.stderr); } if include_timings && let Some(t) = &response.timings { eprintln!( "[timings][{}] resolve_us={} run_us={} total_us={} fast_selector={} cache={}", selector, t.resolve_selector_us, t.run_task_us, t.total_us, t.used_fast_selector, t.used_cache ); } } fn default_socket_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".flow") .join("run") .join("ai-taskd.sock") } fn read_optional_env(key: &str) -> Option<String> { let raw = env::var(key).ok()?; let trimmed = raw.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } fn print_help() { println!("ai-taskd-client"); println!("Usage:"); println!( " ai-taskd-client [--root PATH] [--socket PATH] [--protocol json|msgpack] [--no-cache] [--capture-output] [--timings] <selector> [-- <args...>]" ); println!( " ai-taskd-client [--root PATH] [--socket PATH] [--protocol json|msgpack] [--no-cache] [--capture-output] [--timings] --batch-stdin" ); println!(); println!("Examples:"); println!(" ai-taskd-client ai:flow/noop"); println!(" ai-taskd-client --protocol msgpack --timings ai:flow/noop"); println!( " printf 'ai:flow/noop\\nai:flow/dev-check -- --quick\\n' | ai-taskd-client --batch-stdin" ); } ================================================ FILE: src/bin/lin.rs ================================================ include!("../main.rs"); ================================================ FILE: src/branches.rs ================================================ //! Branch discovery and selection utilities. use std::cmp::Ordering; use std::collections::HashSet; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::ai_server; use crate::cli::{ BranchAiOpts, BranchFindOpts, BranchListOpts, BranchesAction, BranchesCommand, SwitchCommand, }; use crate::sync; #[derive(Debug, Clone)] struct BranchEntry { name: String, subject: String, upstream: Option<String>, is_remote: bool, } pub fn run(cmd: BranchesCommand) -> Result<()> { match cmd.action { Some(BranchesAction::List(opts)) => run_list(opts), Some(BranchesAction::Find(opts)) => run_find(opts), Some(BranchesAction::Ai(opts)) => run_ai(opts), None => run_list(BranchListOpts { remote: false, limit: 40, }), } } fn run_list(opts: BranchListOpts) -> Result<()> { let branches = collect_branches(opts.remote)?; if branches.is_empty() { println!("No branches found."); return Ok(()); } let limit = opts.limit.max(1); for entry in branches.iter().take(limit) { print_branch(entry); } Ok(()) } fn run_find(opts: BranchFindOpts) -> Result<()> { let query = opts.query.trim().to_string(); if query.is_empty() { bail!("Query cannot be empty"); } let branches = collect_branches(opts.remote)?; if branches.is_empty() { bail!("No branches available to search"); } let ranked = rank_branches(&query, &branches); if ranked.is_empty() { bail!("No branches matched query '{}'.", query); } let limit = opts.limit.max(1); for (_, entry) in ranked.iter().take(limit) { print_branch(entry); } if opts.switch { let best = ranked .first() .map(|(_, entry)| (*entry).clone()) .context("No match available to switch")?; println!("\nSwitching to {}...", best.name); switch_to_entry(&best)?; } Ok(()) } fn run_ai(opts: BranchAiOpts) -> Result<()> { let query = opts.query.trim().to_string(); if query.is_empty() { bail!("Query cannot be empty"); } let branches = collect_branches(opts.remote)?; if branches.is_empty() { bail!("No branches available for AI matching"); } let candidates = top_candidates_for_ai(&query, &branches, opts.limit.max(1)); let prompt = build_ai_prompt(&query, &candidates); let response = ai_server::quick_prompt(&prompt, opts.model.as_deref(), opts.url.as_deref(), None)?; let cleaned_response = response.trim().trim_matches('`').trim(); if cleaned_response.eq_ignore_ascii_case("none") { println!("AI selected no matching branch."); return Ok(()); } let selected_name = parse_ai_branch_response(&response, &candidates) .with_context(|| format!("Could not parse AI branch response: {}", response.trim()))?; let selected = candidates .iter() .find(|e| e.name == selected_name) .cloned() .context("AI selected branch that is not in candidate list")?; println!("Selected branch:"); print_branch(&selected); if opts.switch { println!("\nSwitching to {}...", selected.name); switch_to_entry(&selected)?; } Ok(()) } fn print_branch(entry: &BranchEntry) { let mut line = if entry.is_remote { format!("{} [remote]", entry.name) } else { entry.name.clone() }; if let Some(upstream) = entry.upstream.as_deref() { if !upstream.is_empty() { line.push_str(&format!(" -> {}", upstream)); } } if !entry.subject.is_empty() { line.push_str(&format!(" :: {}", entry.subject)); } println!("{}", line); } fn collect_branches(include_remote: bool) -> Result<Vec<BranchEntry>> { let mut out = collect_local_branches()?; if include_remote { out.extend(collect_remote_branches()?); } Ok(out) } fn collect_local_branches() -> Result<Vec<BranchEntry>> { let raw = git_capture(&[ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%00%(upstream:short)%00%(subject)", "refs/heads", ])?; let mut branches = Vec::new(); for line in raw.lines() { let mut parts = line.split('\0'); let name = parts.next().unwrap_or("").trim(); if name.is_empty() { continue; } let upstream = parts.next().unwrap_or("").trim().to_string(); let subject = parts.next().unwrap_or("").trim().to_string(); branches.push(BranchEntry { name: name.to_string(), subject, upstream: if upstream.is_empty() { None } else { Some(upstream) }, is_remote: false, }); } Ok(branches) } fn collect_remote_branches() -> Result<Vec<BranchEntry>> { let raw = git_capture(&[ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%00%(subject)", "refs/remotes", ])?; let mut branches = Vec::new(); let mut seen = HashSet::new(); for line in raw.lines() { let mut parts = line.split('\0'); let name = parts.next().unwrap_or("").trim(); if name.is_empty() || name.ends_with("/HEAD") { continue; } if !seen.insert(name.to_string()) { continue; } let subject = parts.next().unwrap_or("").trim().to_string(); branches.push(BranchEntry { name: name.to_string(), subject, upstream: None, is_remote: true, }); } Ok(branches) } fn rank_branches<'a>(query: &str, branches: &'a [BranchEntry]) -> Vec<(i64, &'a BranchEntry)> { let q = query.to_ascii_lowercase(); let tokens: Vec<&str> = q.split_whitespace().filter(|t| !t.is_empty()).collect(); let mut ranked = Vec::new(); for (idx, entry) in branches.iter().enumerate() { let hay_name = entry.name.to_ascii_lowercase(); let hay_subject = entry.subject.to_ascii_lowercase(); let mut score: i64 = 0; let mut matched = false; if let Some(pos) = hay_name.find(&q) { matched = true; score += 10_000 - pos as i64; } if let Some(pos) = hay_subject.find(&q) { matched = true; score += 3_000 - pos as i64; } let mut all_tokens_match = true; for token in &tokens { if hay_name.contains(token) { score += 700; } else if hay_subject.contains(token) { score += 250; } else { all_tokens_match = false; } } if !tokens.is_empty() && all_tokens_match { matched = true; score += 1_500; } if !matched { continue; } // Stable tie-break using recency order from git listing (earlier index first). score -= idx as i64; ranked.push((score, entry)); } ranked.sort_by(|a, b| match b.0.cmp(&a.0) { Ordering::Equal => a.1.name.cmp(&b.1.name), other => other, }); ranked } fn top_candidates_for_ai(query: &str, branches: &[BranchEntry], limit: usize) -> Vec<BranchEntry> { let mut candidates: Vec<BranchEntry> = rank_branches(query, branches) .into_iter() .map(|(_, entry)| (*entry).clone()) .take(limit) .collect(); if candidates.is_empty() { candidates = branches.iter().take(limit).cloned().collect(); } candidates } fn build_ai_prompt(query: &str, candidates: &[BranchEntry]) -> String { let mut prompt = String::new(); prompt.push_str("You are selecting a git branch for a user query.\\n"); prompt.push_str("Return exactly one line in one of these formats:\\n"); prompt.push_str("branch:<exact branch name>\\n"); prompt.push_str("none\\n\\n"); prompt.push_str("Candidate branches:\\n"); for entry in candidates { let remote = if entry.is_remote { "remote" } else { "local" }; prompt.push_str(&format!( "- {} [{}] :: {}\\n", entry.name, remote, if entry.subject.is_empty() { "(no subject)" } else { &entry.subject } )); } prompt.push_str(&format!("\\nUser query: {}\\n", query)); prompt.push_str("Answer:"); prompt } fn parse_ai_branch_response(response: &str, candidates: &[BranchEntry]) -> Option<String> { let cleaned = response.trim().trim_matches('`').trim(); if cleaned.eq_ignore_ascii_case("none") { return None; } if let Some(name) = cleaned.strip_prefix("branch:") { let selected = name.trim(); if candidates.iter().any(|c| c.name == selected) { return Some(selected.to_string()); } } // Fallback: accept exact branch name response. if candidates.iter().any(|c| c.name == cleaned) { return Some(cleaned.to_string()); } None } fn switch_to_entry(entry: &BranchEntry) -> Result<()> { if entry.is_remote { let (remote, branch) = entry .name .split_once('/') .context("Remote branch name is malformed")?; sync::run_switch(SwitchCommand { branch: branch.to_string(), remote: Some(remote.to_string()), preserve: true, no_preserve: false, stash: true, no_stash: false, sync: false, })?; } else { sync::run_switch(SwitchCommand { branch: entry.name.clone(), remote: None, preserve: true, no_preserve: false, stash: true, no_stash: false, sync: false, })?; } Ok(()) } fn git_capture(args: &[&str]) -> Result<String> { let output = Command::new("git") .args(args) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { bail!("git {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } ================================================ FILE: src/changes.rs ================================================ use std::collections::BTreeMap; use std::fs; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use chrono::Utc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::cli::{ChangesAction, ChangesCommand, DiffCommand}; use crate::{ai, config, env}; fn trace_enabled() -> bool { matches!( std::env::var("FLOW_DIFF_TRACE") .or_else(|_| std::env::var("FLOW_TRACE_DIFF")) .or_else(|_| std::env::var("FLOW_DEBUG")) .ok() .as_deref(), Some("1") | Some("true") | Some("yes") ) } fn trace(msg: &str) { if trace_enabled() { eprintln!("[diff] {}", msg); } } pub fn run(cmd: ChangesCommand) -> Result<()> { match cmd.action { Some(ChangesAction::CurrentDiff) => { print_current_diff()?; } Some(ChangesAction::Accept { diff, file }) => { apply_diff(diff, file)?; } None => { bail!( "Missing changes subcommand. Use: f changes current-diff | f changes accept <diff>" ); } } Ok(()) } pub fn run_diff(cmd: DiffCommand) -> Result<()> { match cmd.hash { Some(hash) => { if !cmd.env.is_empty() { bail!("Env keys are only supported when creating a bundle."); } trace(&format!("unroll bundle: {}", hash)); unroll_bundle(&hash) } None => { let env_keys = normalize_env_keys(&cmd.env)?; trace(&format!("create bundle (env keys: {})", env_keys.len())); create_bundle(&env_keys) } } } fn repo_root() -> Result<PathBuf> { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output() .context("failed to run git rev-parse")?; if !output.status.success() { bail!("Not a git repository."); } let root = String::from_utf8_lossy(&output.stdout).trim().to_string(); if root.is_empty() { bail!("Unable to resolve git root."); } trace(&format!("repo root: {}", root)); Ok(PathBuf::from(root)) } fn git_output_in(repo_root: &Path, args: &[&str]) -> Result<(String, bool)> { let output = Command::new("git") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let ok = output.status.success(); if !ok && stdout.is_empty() { bail!("git {} failed: {}", args.join(" "), stderr.trim()); } Ok((stdout, ok)) } fn git_ref_exists(repo_root: &Path, reference: &str) -> Result<bool> { let full_ref = format!("{}^{{commit}}", reference); let output = Command::new("git") .current_dir(repo_root) .args(["rev-parse", "--verify", &full_ref]) .output() .with_context(|| format!("failed to verify git ref {}", reference))?; Ok(output.status.success()) } fn resolve_base_ref(repo_root: &Path) -> Result<String> { let candidates = ["main", "origin/main", "master", "origin/master"]; for candidate in candidates { if git_ref_exists(repo_root, candidate)? { trace(&format!("base ref: {}", candidate)); return Ok(candidate.to_string()); } } trace("base ref fallback: HEAD"); Ok("HEAD".to_string()) } fn list_untracked(repo_root: &Path) -> Result<Vec<String>> { let (status, _ok) = git_output_in(repo_root, &["status", "--porcelain"])?; let mut untracked = Vec::new(); for line in status.lines() { if let Some(path) = line.strip_prefix("?? ") { if !path.trim().is_empty() { untracked.push(path.trim().to_string()); } } } Ok(untracked) } fn print_current_diff() -> Result<()> { let repo_root = repo_root()?; let base_ref = resolve_base_ref(&repo_root)?; let diff = diff_from_base(&repo_root, &base_ref)?; print!("{}", diff); Ok(()) } fn diff_from_base(repo_root: &Path, base_ref: &str) -> Result<String> { trace(&format!("diffing from {}", base_ref)); let (tracked_diff, _ok) = git_output_in(&repo_root, &["diff", "--binary", base_ref])?; let mut diff = tracked_diff; for path in list_untracked(&repo_root)? { let (patch, _ok) = git_output_in( &repo_root, &["diff", "--no-index", "--binary", "--", "/dev/null", &path], )?; diff.push_str(&patch); } Ok(diff) } fn read_diff_input(diff: Option<String>, file: Option<PathBuf>) -> Result<String> { if let Some(file) = file { return fs::read_to_string(&file) .with_context(|| format!("failed to read diff file {}", file.display())); } if let Some(raw) = diff { if raw == "-" { return read_stdin(); } let as_path = PathBuf::from(&raw); if as_path.exists() { return fs::read_to_string(&as_path) .with_context(|| format!("failed to read diff file {}", as_path.display())); } return Ok(raw); } if atty::is(atty::Stream::Stdin) { bail!("No diff provided. Pass a diff string, a file path, or '-' to read stdin."); } read_stdin() } fn read_stdin() -> Result<String> { let mut buffer = String::new(); io::stdin() .read_to_string(&mut buffer) .context("failed to read diff from stdin")?; Ok(buffer) } fn apply_diff(diff: Option<String>, file: Option<PathBuf>) -> Result<()> { let repo_root = repo_root()?; let content = read_diff_input(diff, file)?; if content.trim().is_empty() { bail!("Diff input is empty."); } trace(&format!("applying diff (bytes: {})", content.len())); apply_diff_content(&repo_root, &content)?; println!("Applied diff successfully."); Ok(()) } fn apply_diff_content(repo_root: &Path, content: &str) -> Result<()> { let mut child = Command::new("git") .current_dir(repo_root) .args(["apply", "--whitespace=fix", "-"]) .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() .context("failed to run git apply")?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(content.as_bytes()) .context("failed to write diff to git apply")?; } let status = child.wait().context("failed to wait for git apply")?; if !status.success() { bail!("git apply failed"); } Ok(()) } #[derive(Debug, Serialize, Deserialize)] struct DiffBundle { hash: String, version: u32, created_at: String, repo_root: String, #[serde(default)] project_name: Option<String>, base_ref: String, diff: String, ai_sessions: Vec<serde_json::Value>, #[serde(default)] env_target: Option<String>, #[serde(default)] env_vars: BTreeMap<String, String>, } #[derive(Debug, Serialize)] struct DiffBundlePayload { version: u32, created_at: String, repo_root: String, project_name: Option<String>, base_ref: String, diff: String, ai_sessions: Vec<serde_json::Value>, env_target: Option<String>, env_vars: BTreeMap<String, String>, } #[derive(Debug, Serialize)] struct DiffBundlePayloadV1 { version: u32, created_at: String, repo_root: String, base_ref: String, diff: String, ai_sessions: Vec<serde_json::Value>, } #[derive(Debug, Serialize)] struct DiffBundlePayloadV2 { version: u32, created_at: String, repo_root: String, base_ref: String, diff: String, ai_sessions: Vec<serde_json::Value>, env_target: Option<String>, env_vars: BTreeMap<String, String>, } #[derive(Debug, Serialize, Deserialize)] struct DiffStashRecord { stash_ref: String, created_at: String, repo_root: String, bundle_hash: String, message: String, } fn create_bundle(env_keys: &[String]) -> Result<()> { let repo_root = repo_root()?; let project_name = load_project_name(&repo_root)?; let base_ref = resolve_base_ref(&repo_root)?; let diff = diff_from_base(&repo_root, &base_ref)?; trace(&format!("project: {}", project_name)); let ai_sessions = match ai::get_sessions_for_gitedit(&repo_root) { Ok(sessions) => sessions .into_iter() .filter_map(|session| serde_json::to_value(session).ok()) .collect(), Err(err) => { eprintln!("Warning: failed to collect AI sessions: {}", err); Vec::new() } }; let created_at = Utc::now().to_rfc3339(); let repo_root_str = repo_root.display().to_string(); let (env_target, env_vars) = gather_env_vars(env_keys)?; let payload = DiffBundlePayload { version: 3, created_at: created_at.clone(), repo_root: repo_root_str.clone(), project_name: Some(project_name.clone()), base_ref: base_ref.clone(), diff: diff.clone(), ai_sessions: ai_sessions.clone(), env_target: env_target.clone(), env_vars: env_vars.clone(), }; let hash = bundle_hash(&payload)?; let bundle = DiffBundle { hash: hash.clone(), version: payload.version, created_at: payload.created_at, repo_root: payload.repo_root, project_name: payload.project_name, base_ref: payload.base_ref, diff: payload.diff, ai_sessions: payload.ai_sessions, env_target: payload.env_target, env_vars: payload.env_vars, }; let bundle_path = write_bundle(&bundle)?; trace(&format!("bundle written: {}", bundle_path.display())); println!("Diff hash: {}", hash); println!("Project: {}", project_name); println!("Base ref: {}", base_ref); println!("AI sessions: {}", ai_sessions.len()); if !env_vars.is_empty() { println!("Env vars: {}", env_vars.len()); } println!("Bundle: {}", bundle_path.display()); println!("Unroll: f diff {}", hash); Ok(()) } fn normalize_env_keys(raw: &[String]) -> Result<Vec<String>> { let mut out = Vec::new(); for item in raw { let trimmed = item.trim(); if trimmed.is_empty() { continue; } if trimmed.starts_with('[') && trimmed.ends_with(']') { let parsed: Vec<String> = serde_json::from_str(trimmed).context("failed to parse --env JSON array")?; for key in parsed { let key = key.trim().to_string(); if !key.is_empty() { out.push(key); } } continue; } if trimmed.contains(',') { for key in trimmed.split(',') { let key = key.trim().to_string(); if !key.is_empty() { out.push(key); } } continue; } out.push(trimmed.to_string()); } out.sort(); out.dedup(); Ok(out) } fn load_project_name(repo_root: &Path) -> Result<String> { let flow_path = repo_root.join("flow.toml"); if !flow_path.exists() { bail!("flow.toml not found in repo root."); } trace(&format!( "reading project name from {}", flow_path.display() )); let cfg = config::load(&flow_path) .with_context(|| format!("failed to read {}", flow_path.display()))?; let name = cfg .project_name .ok_or_else(|| anyhow::anyhow!("flow.toml missing 'name'"))?; Ok(name) } fn ensure_project_match(repo_root: &Path, bundle: &DiffBundle) -> Result<()> { let bundle_name = bundle.project_name.as_deref().ok_or_else(|| { anyhow::anyhow!("Diff bundle missing project name. Recreate with the latest flow.") })?; let current_name = load_project_name(repo_root)?; if bundle_name != current_name { bail!( "Project mismatch. Bundle is for '{}' but this repo is '{}'.", bundle_name, current_name ); } trace(&format!("project match: {}", current_name)); Ok(()) } fn gather_env_vars(keys: &[String]) -> Result<(Option<String>, BTreeMap<String, String>)> { if keys.is_empty() { return Ok((None, BTreeMap::new())); } let vars = read_personal_local_env(keys)?; if vars.is_empty() { eprintln!("Warning: no matching env vars found in local store."); return Ok((Some("personal".to_string()), BTreeMap::new())); } let missing: Vec<_> = keys .iter() .filter(|key| !vars.contains_key(*key)) .cloned() .collect(); if !missing.is_empty() { eprintln!("Warning: missing env vars: {}", missing.join(", ")); } trace(&format!("env keys bundled: {}", vars.len())); Ok((Some("personal".to_string()), vars)) } fn read_personal_local_env(keys: &[String]) -> Result<BTreeMap<String, String>> { let path = local_env_path("personal")?; trace(&format!("reading local env: {}", path.display())); if !path.exists() { return Ok(BTreeMap::new()); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let vars = env::parse_env_file(&content); if keys.is_empty() { return Ok(vars.into_iter().collect()); } let mut filtered = BTreeMap::new(); for key in keys { if let Some(value) = vars.get(key) { filtered.insert(key.clone(), value.clone()); } } Ok(filtered) } fn unroll_bundle(id: &str) -> Result<()> { let (bundle, source_path) = read_bundle(id)?; let repo_root = repo_root()?; ensure_project_match(&repo_root, &bundle)?; let output_dir = repo_root.join(".ai").join("diffs").join(&bundle.hash); fs::create_dir_all(&output_dir)?; let diff_path = output_dir.join("diff.patch"); fs::write(&diff_path, &bundle.diff) .with_context(|| format!("failed to write {}", diff_path.display()))?; let sessions_path = output_dir.join("sessions.json"); let sessions_json = serde_json::to_string_pretty(&bundle.ai_sessions) .context("failed to serialize AI sessions")?; fs::write(&sessions_path, sessions_json) .with_context(|| format!("failed to write {}", sessions_path.display()))?; let meta = serde_json::json!({ "hash": bundle.hash, "version": bundle.version, "created_at": bundle.created_at, "repo_root": bundle.repo_root, "base_ref": bundle.base_ref, "session_count": bundle.ai_sessions.len(), "env_count": bundle.env_vars.len(), "diff_bytes": bundle.diff.as_bytes().len(), "source_bundle": source_path.as_ref().map(|p| p.display().to_string()), }); let meta_path = output_dir.join("meta.json"); fs::write(&meta_path, serde_json::to_string_pretty(&meta)?) .with_context(|| format!("failed to write {}", meta_path.display()))?; trace(&format!("unroll output: {}", output_dir.display())); let stash_ref = stash_if_dirty(&repo_root, &bundle.hash)?; if let Err(err) = apply_diff_content(&repo_root, &bundle.diff) { if let Some(stash_ref) = stash_ref { eprintln!( "Diff apply failed. Your previous state is stashed: {}", stash_ref ); } return Err(err); } if !bundle.env_vars.is_empty() { apply_env_vars(&bundle)?; } println!("Unrolled diff {} -> {}", bundle.hash, output_dir.display()); if let Some(path) = source_path { println!("Source bundle: {}", path.display()); } if let Some(stash_ref) = stash_ref { println!("Stashed previous state: {}", stash_ref); println!("Restore: git stash pop {}", stash_ref); } Ok(()) } fn bundle_hash(payload: &DiffBundlePayload) -> Result<String> { let bytes = serde_json::to_vec(payload).context("failed to serialize diff bundle")?; let mut hasher = Sha256::new(); hasher.update(bytes); let digest = hasher.finalize(); Ok(hex::encode(digest)) } fn bundle_hash_v1(payload: &DiffBundlePayloadV1) -> Result<String> { let bytes = serde_json::to_vec(payload).context("failed to serialize diff bundle")?; let mut hasher = Sha256::new(); hasher.update(bytes); let digest = hasher.finalize(); Ok(hex::encode(digest)) } fn bundle_hash_v2(payload: &DiffBundlePayloadV2) -> Result<String> { let bytes = serde_json::to_vec(payload).context("failed to serialize diff bundle")?; let mut hasher = Sha256::new(); hasher.update(bytes); let digest = hasher.finalize(); Ok(hex::encode(digest)) } fn bundle_dir() -> Result<PathBuf> { let config_dir = config::ensure_global_config_dir()?; let diffs_dir = config_dir.join("diffs"); fs::create_dir_all(&diffs_dir)?; trace(&format!("bundle dir: {}", diffs_dir.display())); Ok(diffs_dir) } fn write_bundle(bundle: &DiffBundle) -> Result<PathBuf> { let diffs_dir = bundle_dir()?; let path = diffs_dir.join(format!("{}.json", bundle.hash)); let payload = serde_json::to_string_pretty(bundle).context("failed to serialize bundle")?; fs::write(&path, payload).with_context(|| format!("failed to write {}", path.display()))?; Ok(path) } fn read_bundle(id: &str) -> Result<(DiffBundle, Option<PathBuf>)> { let candidate = PathBuf::from(id); let path = if candidate.exists() { candidate } else { bundle_dir()?.join(format!("{}.json", id)) }; if !path.exists() { trace(&format!("bundle lookup failed: {}", path.display())); bail!( "Diff bundle not found. Expected {} or pass a path to a bundle file.", path.display() ); } trace(&format!("bundle read: {}", path.display())); let data = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let bundle: DiffBundle = serde_json::from_str(&data) .with_context(|| format!("failed to parse {}", path.display()))?; let expected = if bundle.version <= 1 { let payload = DiffBundlePayloadV1 { version: bundle.version, created_at: bundle.created_at.clone(), repo_root: bundle.repo_root.clone(), base_ref: bundle.base_ref.clone(), diff: bundle.diff.clone(), ai_sessions: bundle.ai_sessions.clone(), }; bundle_hash_v1(&payload)? } else if bundle.version == 2 { let payload = DiffBundlePayloadV2 { version: bundle.version, created_at: bundle.created_at.clone(), repo_root: bundle.repo_root.clone(), base_ref: bundle.base_ref.clone(), diff: bundle.diff.clone(), ai_sessions: bundle.ai_sessions.clone(), env_target: bundle.env_target.clone(), env_vars: bundle.env_vars.clone(), }; bundle_hash_v2(&payload)? } else { let payload = DiffBundlePayload { version: bundle.version, created_at: bundle.created_at.clone(), repo_root: bundle.repo_root.clone(), project_name: bundle.project_name.clone(), base_ref: bundle.base_ref.clone(), diff: bundle.diff.clone(), ai_sessions: bundle.ai_sessions.clone(), env_target: bundle.env_target.clone(), env_vars: bundle.env_vars.clone(), }; bundle_hash(&payload)? }; if expected != bundle.hash { eprintln!( "Warning: bundle hash mismatch (expected {}, got {}).", expected, bundle.hash ); } Ok((bundle, Some(path))) } fn apply_env_vars(bundle: &DiffBundle) -> Result<()> { let target = bundle.env_target.as_deref().unwrap_or("personal"); let path = local_env_path(target)?; let mut vars: BTreeMap<String, String> = if path.exists() { let content = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; env::parse_env_file(&content).into_iter().collect() } else { BTreeMap::new() }; for (key, value) in &bundle.env_vars { vars.insert(key.clone(), value.clone()); } write_local_env(&path, target, "production", &vars)?; println!( "Applied {} env var(s) to {}", bundle.env_vars.len(), path.display() ); Ok(()) } fn local_env_path(target: &str) -> Result<PathBuf> { let config_dir = config::ensure_global_config_dir()?; let dir = config_dir .join("env-local") .join(sanitize_env_segment(target)); fs::create_dir_all(&dir)?; Ok(dir.join("production.env")) } fn stash_log_path() -> Result<PathBuf> { let config_dir = config::ensure_global_config_dir()?; let dir = config_dir.join("diffs"); fs::create_dir_all(&dir)?; Ok(dir.join("stashes.json")) } fn record_stash(repo_root: &Path, stash_ref: &str, bundle_hash: &str, message: &str) -> Result<()> { let path = stash_log_path()?; let mut records: Vec<DiffStashRecord> = if path.exists() { match fs::read_to_string(&path) { Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(), Err(_) => Vec::new(), } } else { Vec::new() }; records.push(DiffStashRecord { stash_ref: stash_ref.to_string(), created_at: Utc::now().to_rfc3339(), repo_root: repo_root.display().to_string(), bundle_hash: bundle_hash.to_string(), message: message.to_string(), }); let payload = serde_json::to_string_pretty(&records)?; fs::write(&path, payload).with_context(|| format!("failed to write {}", path.display()))?; trace(&format!("recorded stash: {}", stash_ref)); Ok(()) } fn sanitize_env_segment(value: &str) -> String { let mut out = String::new(); let mut last_sep = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { out.push(ch); last_sep = false; } else if !last_sep { out.push('_'); last_sep = true; } } let trimmed = out.trim_matches('_').to_string(); if trimmed.is_empty() { "unnamed".to_string() } else { trimmed } } fn write_local_env( path: &Path, target: &str, environment: &str, vars: &BTreeMap<String, String>, ) -> Result<()> { let keys: Vec<_> = vars.keys().collect(); let mut content = String::new(); content.push_str(&format!( "# Local env store (flow)\n# Target: {}\n# Environment: {}\n", target, environment )); for key in keys { let value = &vars[key]; let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{key}=\"{escaped}\"\n")); } fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn stash_if_dirty(repo_root: &Path, bundle_hash: &str) -> Result<Option<String>> { let (status, _ok) = git_output_in(repo_root, &["status", "--porcelain"])?; if status.trim().is_empty() { trace("working tree clean; no stash needed"); return Ok(None); } let message = format!( "flow-diff-{}-{}", &bundle_hash[..bundle_hash.len().min(8)], Utc::now().format("%Y%m%d-%H%M%S") ); let output = Command::new("git") .current_dir(repo_root) .args(["stash", "push", "-u", "-m", &message]) .output() .context("failed to stash working tree")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("failed to stash working tree: {}", stderr.trim()); } let (stash_ref, _ok) = git_output_in(repo_root, &["stash", "list", "-1", "--pretty=%gd"])?; let stash_ref = stash_ref.trim().to_string(); if stash_ref.is_empty() { return Ok(Some(message)); } record_stash(repo_root, &stash_ref, bundle_hash, &message)?; Ok(Some(stash_ref)) } ================================================ FILE: src/cli.rs ================================================ use clap::{Args, Parser, Subcommand, ValueEnum}; use std::{net::IpAddr, path::PathBuf}; use crate::commit::ReviewModelArg; /// Command line interface for the flow daemon / CLI hybrid. #[derive(Parser, Debug)] #[command( name = "flow", version = version_with_build_time(), about = "Your second OS", subcommand_required = false, arg_required_else_help = false )] pub struct Cli { #[command(subcommand)] pub command: Option<Commands>, /// Output all commands in machine-readable JSON format for external tools. #[arg(long, global = true)] pub help_full: bool, } /// Returns version string with relative build time (e.g., "0.1.0 (built 5m ago)") fn version_with_build_time() -> &'static str { use std::sync::OnceLock; static VERSION: OnceLock<String> = OnceLock::new(); // Include the generated timestamp file to force recompilation when it changes const BUILD_TIMESTAMP_STR: &str = include_str!(concat!(env!("OUT_DIR"), "/build_timestamp.txt")); VERSION.get_or_init(|| { let version = env!("CARGO_PKG_VERSION"); let build_timestamp: u64 = BUILD_TIMESTAMP_STR.trim().parse().unwrap_or(0); if build_timestamp == 0 { return version.to_string(); } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let elapsed = now.saturating_sub(build_timestamp); let relative = format_relative_time(elapsed); format!("{version} (built {relative})") }) } fn format_relative_time(seconds: u64) -> String { if seconds < 60 { format!("{}s ago", seconds) } else if seconds < 3600 { format!("{}m ago", seconds / 60) } else if seconds < 86400 { let hours = seconds / 3600; format!("{}h ago", hours) } else { let days = seconds / 86400; format!("{}d ago", days) } } #[derive(Subcommand, Debug)] pub enum Commands { #[command( about = "Fuzzy search global commands/tasks without a project flow.toml.", long_about = "Browse global commands and tasks from your global flow config (e.g., ~/.config/flow/flow.toml). Useful when you are outside a project directory.", alias = "s" )] Search, #[command( about = "Run tasks from the global flow config.", long_about = "Run tasks defined in ~/.config/flow/flow.toml without project discovery.", alias = "g" )] Global(GlobalCommand), #[command( about = "Ensure the background hub daemon is running (spawns it if missing).", long_about = "Checks the /health endpoint on the configured host/port (defaults to 127.0.0.1:9050). If unreachable, a daemon is launched in the background using the lin runtime recorded via `lin register` (or PATH), then a TUI opens so you can inspect managed servers and aggregated logs." )] Hub(HubCommand), #[command( about = "Scaffold a new flow.toml in the current directory.", long_about = "Creates a starter flow.toml with stub tasks (setup, dev) so you can fill in commands later." )] Init(InitOpts), #[command( about = "Output shell integration script.", long_about = "Prints shell wrapper functions for commands like `f new` that need to cd. Add `eval (f shell-init fish)` to your fish config." )] ShellInit(ShellInitOpts), #[command( about = "Manage shell integration.", long_about = "Helper commands for shell integration like refreshing the current session." )] Shell(ShellCommand), #[command( about = "Create a new project from a template.", long_about = "Create a new project from ~/new/<template>. Path resolution:\n f new <template> → ./<template>\n f new <template> zerg → ~/code/zerg\n f new <template> ./foo → ./foo\n f new <template> ~/path → ~/path" )] New(NewOpts), #[command( about = "Home setup and config repo management.", long_about = "Set up your home environment or clone a GitHub config repo into ~/config, optionally pulling an internal repo into ~/config/i, then applying symlinked configs." )] Home(HomeCommand), #[command( about = "Archive the current project to ~/archive/code.", long_about = "Copies the current project into ~/archive/code/<project>-<message> so you can keep a snapshot outside git history." )] Archive(ArchiveOpts), #[command( about = "Verify required tools and shell integrations.", long_about = "Checks for flox (for managed deps), lin (hub helper), and direnv + shell hook presence." )] Doctor(DoctorOpts), #[command( about = "Ensure your system matches Flow's expectations.", long_about = "Enforces fish shell, installs flow shell integration, and runs doctor checks." )] Health(HealthOpts), #[command( about = "Check project invariants from flow.toml against working tree or staged changes.", alias = "inv" )] Invariants(InvariantsOpts), #[command( about = "Fuzzy search task history or list available tasks.", long_about = "Search through previously run tasks (most recent first) or list project tasks from flow.toml plus AI MoonBit tasks under .ai/tasks/*.mbt." )] Tasks(TasksCommand), #[command( about = "Run an AI task via the low-latency fast client path.", long_about = "Dispatches AI task selectors through the fast daemon client when available (fai / ai-taskd-client), with safe fallback to Flow daemon execution." )] Fast(FastRunOpts), #[command( about = "Bring a project up using lifecycle conventions.", long_about = "Runs optional local-domain setup from [lifecycle.domains], then runs the project up task. By default it tries task 'up', then falls back to 'dev'." )] Up(LifecycleRunOpts), #[command( about = "Bring a project down using lifecycle conventions.", long_about = "Runs the project down task (default 'down'), then optional lifecycle domain teardown." )] Down(LifecycleRunOpts), #[command( about = "Create a local AI scratch test file under .ai/test.", long_about = "Creates a gitignored test scaffold under .ai/test for fast AI-generated validation tests. Intended for local iteration without polluting tracked test suites." )] AiTestNew(AiTestNewOpts), /// Execute a specific project task (hidden; used by the palette and task shortcuts). #[command(hide = true)] Run(TaskRunOpts), /// Invoke tasks directly via `f <task>` without typing `run`. #[command(external_subcommand)] TaskShortcut(Vec<String>), #[command(about = "Show the last task input and its output/error.")] LastCmd, #[command(about = "Show the last task run (command, status, and output) recorded by flow.")] LastCmdFull, #[command(about = "Show the last fish shell command and output (from fish io-trace).")] FishLast, #[command(about = "Show full details of the last fish shell command.")] FishLastFull, #[command(about = "Install traced fish shell (fish fork with always-on I/O tracing).")] FishInstall(FishInstallOpts), #[command(about = "Re-run the last task executed in this project.")] Rerun(RerunOpts), #[command( about = "List running flow processes for the current project.", long_about = "Lists flow-started processes tracked for this project. Use --all to see processes across all projects." )] Ps(ProcessOpts), #[command( about = "Stop running flow processes.", long_about = "Kill flow-started processes by task name, PID, or all for the project. Sends SIGTERM first, then SIGKILL after timeout." )] Kill(KillOpts), #[command( about = "View logs from running or recent tasks.", long_about = "Tail the log output of a running task. Use -f to follow in real-time." )] Logs(TaskLogsOpts), #[command( about = "Quick traces for AI + task runs from jazz2 state.", long_about = "Print recent AI agent events and Flow task runs stored in the shared jazz2 state. Use --follow to stream.", alias = "traces" )] Trace(TraceCommand), #[command( about = "Manage anonymous usage analytics preferences and local queue.", long_about = "Inspect, enable/disable, export, or purge local anonymous usage analytics events." )] Analytics(AnalyticsCommand), #[command( about = "List registered projects.", long_about = "Shows all projects that have been registered (projects with a 'name' field in flow.toml)." )] Projects, #[command( about = "Fuzzy search AI sessions across all projects and copy context.", long_about = "Browse AI sessions (Claude, Codex, Cursor) across all projects. On selection, copies the session context since last checkpoint to clipboard for passing to another session.", alias = "ss" )] Sessions(SessionsOpts), #[command( about = "Show or set the active project.", long_about = "The active project is used as a fallback for commands like `f logs` when not in a project directory." )] Active(ActiveOpts), #[command( about = "Start the flow HTTP server for log ingestion and queries.", long_about = "Runs an HTTP server with endpoints for log ingestion (/logs/ingest) and queries (/logs/query).\n\nAlso provides a lightweight PR edit watcher for ~/.flow/pr-edit:\n GET /pr-edit/status\n POST /pr-edit/rescan" )] Server(ServerOpts), #[command( about = "Open the Flow web UI for this project.", long_about = "Serves the .ai/web UI and project metadata (including OpenAPI when available), then opens it in your browser." )] Web(WebOpts), #[command( about = "Match a natural language query to a task using LM Studio.", long_about = "Uses a local LM Studio model to intelligently match your query to an available task. Requires LM Studio running on localhost:1234 (or custom port).", alias = "m" )] Match(MatchOpts), #[command( about = "Ask the AI server to suggest a task or Flow command.", long_about = "Uses the local AI server (zerg/ai) to match your query to a flow.toml task or a Flow CLI command you can run.", alias = "a" )] Ask(AskOpts), #[command( about = "List and search git branches quickly.", long_about = "Fast branch discovery for local + remote branches. Supports lexical search and AI-assisted natural-language matching via the local AI server (zerg/ai).", alias = "br" )] Branches(BranchesCommand), #[command( about = "AI-powered commit with code review and optional GitEdit sync.", long_about = "Stages all changes (or only paths passed via --path), commits quickly by default, starts deferred Codex deep review in the background, and syncs AI sessions to gitedit.dev when enabled in global config. Use --slow to run blocking review before commit.", alias = "c" )] Commit(CommitOpts), #[command( about = "Manage the commit review queue.", long_about = "List, inspect, approve, or drop queued commits before they push to remote.", alias = "cq" )] CommitQueue(CommitQueueCommand), #[command( about = "Manage deferred deep-review todos for queued commits.", long_about = "Workflow-friendly wrapper around commit queue reviews. Use `codex --all` to run deep Codex reviews across pending commits, then approve after issues are addressed.", alias = "rt" )] ReviewsTodo(ReviewsTodoCommand), #[command( about = "Create a GitHub PR from current changes or a queued commit.", long_about = "By default, stages and commits current changes (or only paths passed via --path) into the queue, then creates/updates a GitHub PR for the latest queued commit. Use --no-commit to skip committing and create a PR from an existing queued commit.\n\nSpecial:\n `f pr open` opens the PR for the current branch (or falls back to the queued commit).\n `f pr open edit` opens a local markdown editor file and syncs PR title/body on save.\n `f pr feedback [<number|url>] [--todo]` fetches review comments/reviews and can store them as local todos." )] Pr(PrOpts), #[command( about = "Manage personal tooling ignore policy across repos.", long_about = "Audit and clean personal tooling ignore patterns from project .gitignore files. This helps keep external repositories free of local-only patterns like .beads/ and .rise/." )] Gitignore(GitignoreCommand), #[command( about = "Legacy recipe command (prefer task-centric .ai/tasks/.mbt).", long_about = "Legacy compatibility command for recipe files. Prefer task-centric workflows with flow.toml tasks + .ai/tasks/*.mbt.", hide = true )] Recipe(RecipeCommand), #[command( about = "Open queued commits for review in Rise.", long_about = "Open the latest queued commit (or a specific one in the future) in Rise's review UI.", alias = "rv" )] Review(ReviewCommand), #[command( about = "Simple AI commit without code review.", long_about = "Stages all changes (or only paths passed via --path), uses OpenAI to generate a commit message from the diff, commits, and pushes. No code review.", visible_alias = "commitSimple", hide = true )] CommitSimple(CommitOpts), #[command( about = "AI commit with code review (GitEdit sync honors config).", long_about = "Like 'commit' but without forcing gitedit.dev sync; respects the global gitedit setting.", alias = "cc", visible_alias = "commitWithCheck", hide = true )] CommitWithCheck(CommitOpts), #[command( about = "Undo the last undoable action (commit, push).", long_about = "Reverts the last recorded action. For commits, resets with --soft to keep changes staged. For pushes, force pushes the previous state.", alias = "u" )] Undo(UndoCommand), #[command( about = "Fix issues in the repo with help from Hive.", long_about = "Optionally unroll the last commit, then run a Hive agent to fix the issue (e.g., leaked secrets)." )] Fix(FixOpts), #[command( about = "Fix common TOML syntax errors in flow.toml.", long_about = "Automatically fixes common issues in flow.toml that can break parsing, such as invalid escape sequences (\\$, \\n in basic strings), unclosed quotes, and other TOML syntax errors." )] Fixup(FixupOpts), #[command( about = "Share or apply git diffs without remotes.", long_about = "Print the current git diff for sharing or apply a diff string/file to this repo. Useful when git pull/push isn't available." )] Changes(ChangesCommand), #[command( about = "Create or unpack a shareable diff bundle.", long_about = "Generates a diff against the main branch (including untracked files) plus AI sessions, stores it by hash, or unrolls a stored bundle by hash." )] Diff(DiffCommand), #[command( about = "Hash files or sessions with unhash and copy a share link.", long_about = "Runs the unhash CLI, then copies unstash./<hash> to clipboard and prints the hash/link." )] Hash(HashOpts), #[command( about = "Manage background daemons (start, stop, status).", long_about = "Start, stop, and monitor background daemons defined in flow.toml. Daemons are long-running processes like sync servers, API servers, or file watchers.", alias = "d" )] Daemon(DaemonCommand), #[command( about = "Run the Flow supervisor (daemon manager).", long_about = "Starts or checks the Flow supervisor, which manages background daemons via IPC." )] Supervisor(SupervisorCommand), #[command( about = "Manage AI coding sessions (Claude Code).", long_about = "Track, list, and resume Claude Code sessions for the current project. Sessions are stored in .ai/sessions/claude/ and can be named for easy recall." )] Ai(AiCommand), #[command(about = "Start or continue Codex session.", alias = "cx")] Codex { #[command(subcommand)] action: Option<ProviderAiAction>, }, #[command( about = "Read Cursor agent transcripts for this project.", alias = "cu" )] Cursor { #[command(subcommand)] action: Option<ProviderAiAction>, }, #[command(about = "Start or continue Claude session.", alias = "cl")] Claude { #[command(subcommand)] action: Option<ProviderAiAction>, }, #[command( about = "Manage project env vars and cloud sync.", long_about = "With no arguments, lists project env vars for the current environment. Use subcommands to manage env vars via the cloud backend or run the sync workflow." )] Env(EnvCommand), #[command( about = "Fetch one-time passwords from 1Password Connect.", long_about = "Uses OP_CONNECT_HOST + OP_CONNECT_TOKEN (from env or Flow personal env store) to fetch an item TOTP." )] Otp(OtpCommand), #[command( about = "Authenticate Flow AI via myflow.", long_about = "Starts a device auth flow for myflow, storing a token for AI-powered CLI features." )] Auth(AuthOpts), #[command( about = "Onboard third-party services (Stripe, etc.) with guided env setup.", long_about = "Guided setup flows for external services. Prompts for required env vars, stores them in the cloud backend, and can apply them to Cloudflare." )] Services(ServicesCommand), #[command( about = "Manage macOS launch agents and daemons.", long_about = "List, audit, enable, and disable macOS launchd services. Helps keep your startup clean by identifying bloatware and unwanted background processes." )] Macos(MacosCommand), #[command( about = "Manage SSH keys via the cloud backend.", long_about = "Generate, store, and unlock SSH keys stored in cloud personal env vars, then wire git to use the Flow SSH agent." )] Ssh(SshCommand), #[command( about = "Manage project todos.", long_about = "Create, list, edit, and complete lightweight todos stored in .ai/todos/todos.json. With no arguments, opens the per-project Bike outliner stored in .ai/todos/<project>.bike." )] Todo(TodoCommand), #[command( about = "Copy an external dependency into ext/ and ignore it.", long_about = "Copies a directory into <project>/ext/<name> and adds ext/ to .gitignore." )] Ext(ExtCommand), #[command( about = "Manage Codex skills (.ai/skills/).", long_about = "Create, list, and manage Codex skills for this project. Skills are stored in .ai/skills/ (gitignored by default) and help Codex understand project-specific workflows." )] Skills(SkillsCommand), #[command( about = "Inspect a URL into a thin, AI-friendly summary.", long_about = "Fetches and normalizes a URL with Cloudflare Browser Rendering markdown first when configured, then the local scraper backend, then a direct fetch fallback. Defaults to a compact summary so it can be safely pasted into AI sessions." )] Url(UrlCommand), #[command( about = "Install or update project dependencies.", long_about = "Detects the package manager from lockfiles and runs install/update at the project root." )] Deps(DepsCommand), #[command( name = "db", about = "Manage databases (Jazz, Postgres).", long_about = "Provision database backends and run database workflows (Jazz worker accounts, Postgres migrations). Defaults are tuned for Planetscale Postgres." )] Db(DbCommand), #[command( about = "Manage AI tools (.ai/tools/*.ts).", long_about = "Create, list, and run TypeScript tools via Bun. Tools are fast, reusable scripts stored in .ai/tools/. Use 'codify' to generate tools from natural language.", alias = "t" )] Tools(ToolsCommand), #[command( about = "Send a proposal notification to Lin for approval.", long_about = "Sends a proposal to the Lin app widget for user approval. Used for human-in-the-loop AI workflows." )] Notify(NotifyCommand), #[command( about = "Browse and analyze git commits with AI session metadata.", long_about = "Fuzzy search through git commits, showing attached AI sessions and review metadata. Supports notable commits and quick actions." )] Commits(CommitsCommand), #[command( name = "seq-rpc", about = "Call seqd RPC v1 via native Rust client.", long_about = "Sends typed JSON RPC requests over Unix socket directly to seqd. Use this for OS-level automation integration without shelling out to `seq rpc`." )] SeqRpc(SeqRpcCommand), #[command( name = "explain-commits", about = "Generate AI explanations for recent commits.", long_about = "Uses AI to generate markdown summaries for git commits. Writes one file per commit to docs/commits/ by default (local generated output). Skips already-processed commits unless --force is used." )] ExplainCommits(ExplainCommitsCommand), #[command( about = "Bootstrap project and run setup task or aliases.", long_about = "Bootstraps the project if needed, creates flow.toml when missing, then runs the 'setup' task or prints shell aliases." )] Setup(SetupOpts), #[command( about = "Invoke gen AI agents.", long_about = "Run gen agents with prompts. Supports project and global agents. Special: flow (flow-aware).", alias = "ag" )] Agents(AgentsCommand), #[command( about = "Manage and run hive agents.", long_about = "Hive agents are MoonBit-powered AI agents with tool use. Agents can be project-local (.flow/agents/) or global (~/.hive/agents/).", alias = "h" )] Hive(HiveCommand), #[command( about = "Sync git repo: pull + upstream merge (push optional).", long_about = "Comprehensive git sync: pulls from tracking/default remote and merges/rebases upstream changes when configured. Use --push to push to the configured git remote (defaults to origin)." )] Sync(SyncCommand), #[command( about = "Checkout a GitHub PR safely.", long_about = "Checks out a pull request by URL/number/branch via GitHub CLI. By default, auto-stashes local changes before checkout and restores them after. Also imports git refs into jj when available." )] Checkout(CheckoutCommand), #[command( about = "Switch to a branch and align upstream tracking.", long_about = "Switches to a target branch (creating it from upstream/origin when needed), updates flow upstream tracking for that branch, and imports git changes into jj when present. Accepts branch names, PR numbers (for example: 123 or #123), and GitHub PR URLs." )] Switch(SwitchCommand), #[command( about = "Push current branch to a configured private mirror remote.", long_about = "Pushes the current branch to a private mirror remote (typically on GitHub). When the repo is a read-only clone (origin == upstream), Flow can repoint origin to your mirror based on FLOW_PUSH_OWNER (or --owner) and push there." )] Push(PushCommand), #[command( about = "Show JJ workflow status optimized for stacked home-branch work.", long_about = "Displays the current JJ workspace, home branch, intake branch, trunk relation, leaf branches, and the working-copy summary. This is intended to replace a raw `jj st` for repos that use a persistent home branch plus review/codex workspaces.", alias = "st" )] Status(StatusOpts), #[command( about = "Jujutsu (jj) workflow helpers.", long_about = "Initialize jj, manage workspaces/bookmarks, and sync with git remotes in a safe, structured flow." )] Jj(JjCommand), #[command( about = "Repair git state (abort rebase/merge, leave detached HEAD).", long_about = "Aborts in-progress git operations (rebase, merge, cherry-pick, revert), resets bisect, and checks out the target branch if HEAD is detached." )] GitRepair(GitRepairOpts), #[command( about = "Show project information.", long_about = "Display project details including git remotes, upstream configuration, and flow.toml settings.", alias = "i" )] Info, #[command( about = "Manage upstream fork workflow.", long_about = "Set up and manage upstream forks. Creates a local 'upstream' branch to cleanly track the original repo, making merges easier." )] Upstream(UpstreamCommand), #[command( about = "Deploy project to host or cloud platform.", long_about = "Deploy your project to a Linux host (via SSH), Cloudflare Workers, or Railway. Automatically detects platform from flow.toml [host], [cloudflare], or [railway] sections." )] Deploy(DeployCommand), #[command( about = "Deploy to production using flow.toml deploy config.", long_about = "Deploys using flow.toml [host], [cloudflare], [railway], or [web] configuration and skips [flow].deploy_task. If a deploy-prod or prod task exists, it will run that task instead.", alias = "production" )] Prod(DeployCommand), #[command( about = "Publish project to gitedit.dev or GitHub.", long_about = "Publish the current project. Without a subcommand, shows a fuzzy picker to choose the target." )] Publish(PublishCommand), #[command( about = "Clone a repository into the current directory (git clone style).", long_about = "Clones into the current working directory by default, matching git clone destination behavior. GitHub inputs are normalized to SSH URLs." )] Clone(CloneOpts), #[command( about = "Clone repositories into a structured local directory.", long_about = "Clone repositories into ~/repos/<owner>/<repo> with SSH URLs and optional upstream setup for forks." )] Repos(ReposCommand), #[command( about = "Browse git repos under ~/code.", long_about = "Fuzzy search git repositories under ~/code and open the selected path. Also includes helpers to migrate AI sessions when paths move." )] Code(CodeCommand), #[command( about = "Move or copy a folder to a new location, preserving symlinks and AI sessions.", long_about = "Migrate a project folder to a new location. Usage:\n f migrate <target> - move current dir to target\n f migrate <source> <target> - move source to target\n f migrate -c <src> <target> - copy instead of move\n f migrate code <relative> - move current dir to ~/code/<relative>\n f migrate --copy code <rel> - copy current dir to ~/code/<relative>\nUpdates ~/bin symlinks (move only). AI sessions are moved or copied based on the mode." )] Migrate(MigrateCommand), #[command( about = "Run tasks in parallel with pretty status display.", long_about = "Execute multiple shell commands in parallel with a real-time status display showing spinners, progress, and output. Useful for running independent tasks concurrently.", alias = "p" )] Parallel(ParallelCommand), #[command( about = "Manage auto-generated documentation in .ai/docs/.", long_about = "AI-maintained documentation that stays in sync with the codebase. Docs are stored in .ai/docs/ and can be updated based on recent commits." )] Docs(DocsCommand), #[command( about = "Upgrade flow to the latest version.", long_about = "Download and install the latest version of flow from GitHub releases. Checks for newer versions and replaces the current executable." )] Upgrade(UpgradeOpts), #[command( about = "Pull ~/code/flow and rebuild the local flow binary.", long_about = "Updates ~/code/flow, runs f deploy in that repo, and reloads the fish shell." )] Latest, #[command( about = "Release a project (registry, GitHub, or task).", long_about = "Release a project based on flow.toml defaults. Supports Flow registry releases, GitHub releases, or running a release task.", alias = "rel" )] Release(ReleaseCommand), #[command( about = "Install a CLI/tool binary (registry, parm, or flox).", long_about = "Install binaries via Flow registry, GitHub releases via parm, or flox. Auto mode tries registry first, then parm, then flox.", alias = "inst" )] Install(InstallCommand), #[command( about = "Manage the Flow registry (tokens, setup).", long_about = "Create registry tokens and wire them into worker secrets and local envs." )] Registry(RegistryCommand), #[command( about = "Zero-cost traced reverse proxy for development.", long_about = "Start a reverse proxy that traces all HTTP requests with zero overhead. Writes trace-summary.json for AI agents to read.", alias = "px" )] Proxy(ProxyCommand), #[command( about = "Manage shared local *.localhost routing on port 80.", long_about = "Manages shared local *.localhost routing (host->target) in ~/.config/flow/local-domains. Default engine uses docker+nginx; optional native engine uses a local domains daemon.", alias = "dom" )] Domains(DomainsCommand), } #[derive(Args, Debug, Clone)] pub struct TracesOpts { /// Max rows per source (default: 40). #[arg(short = 'n', long, default_value = "40")] pub limit: usize, /// Follow and stream new entries. #[arg(short = 'f', long)] pub follow: bool, /// Filter by project path substring. #[arg(long)] pub project: Option<String>, /// Which source to show: all, tasks, ai. #[arg(long, value_enum, default_value = "all")] pub source: TraceSource, } #[derive(Args, Debug, Clone)] pub struct TraceCommand { #[command(subcommand)] pub action: Option<TraceAction>, #[command(flatten)] pub events: TracesOpts, } #[derive(Args, Debug, Clone)] pub struct AnalyticsCommand { #[command(subcommand)] pub action: Option<AnalyticsAction>, } #[derive(Subcommand, Debug, Clone)] pub enum AnalyticsAction { /// Show analytics status and queue metadata. Status, /// Enable anonymous usage analytics. Enable, /// Disable anonymous usage analytics. Disable, /// Print queued analytics events. Export, /// Delete all queued analytics events. Purge, } #[derive(Subcommand, Debug, Clone)] pub enum TraceAction { /// Show full history of the last active AI session for a project path. Session(TraceSessionOpts), } #[derive(Args, Debug, Clone)] pub struct TraceSessionOpts { /// Project path to load the latest session for. #[arg(value_name = "PATH")] pub path: PathBuf, } #[derive(ValueEnum, Clone, Debug)] pub enum TraceSource { All, Tasks, Ai, } // === Proxy Commands === #[derive(Args, Debug, Clone)] pub struct ProxyCommand { #[command(subcommand)] pub action: ProxyAction, } #[derive(Subcommand, Debug, Clone)] pub enum ProxyAction { /// Start the proxy server (reads [[proxies]] from flow.toml). Start(ProxyStartOpts), /// View recent request traces. #[command(alias = "t")] Trace(ProxyTraceOpts), /// Show the last request details. Last(ProxyLastOpts), /// Add a new proxy target. Add(ProxyAddOpts), /// List configured proxy targets. List, /// Stop the proxy server. Stop, } #[derive(Args, Debug, Clone)] pub struct ProxyStartOpts { /// Listen address (e.g., ":8080" or "127.0.0.1:8080"). #[arg(short, long)] pub listen: Option<String>, /// Run in foreground (don't daemonize). #[arg(short, long)] pub foreground: bool, } #[derive(Args, Debug, Clone)] pub struct ProxyTraceOpts { /// Number of records to show. #[arg(short = 'n', long, default_value = "20")] pub count: usize, /// Follow trace in real-time. #[arg(short, long)] pub follow: bool, /// Filter by target name. #[arg(long)] pub target: Option<String>, /// Show only errors (status >= 400). #[arg(long)] pub errors: bool, /// Filter by trace ID. #[arg(long)] pub id: Option<String>, } #[derive(Args, Debug, Clone)] pub struct ProxyLastOpts { /// Show only errors. #[arg(long)] pub errors: bool, /// Filter by target name. #[arg(long)] pub target: Option<String>, /// Include request/response body. #[arg(long)] pub body: bool, } #[derive(Args, Debug, Clone)] pub struct ProxyAddOpts { /// Target address (e.g., "localhost:3000"). pub target: String, /// Proxy name (auto-suggested if not provided). #[arg(short, long)] pub name: Option<String>, /// Host-based routing. #[arg(long)] pub host: Option<String>, /// Path prefix routing. #[arg(long)] pub path: Option<String>, } #[derive(Args, Debug, Clone)] pub struct DomainsCommand { /// Routing engine to use (`docker` default, or `native` for experimental C++ daemon). #[arg(long, value_enum)] pub engine: Option<DomainsEngineArg>, #[command(subcommand)] pub action: Option<DomainsAction>, } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] pub enum DomainsEngineArg { Docker, Native, } #[derive(Subcommand, Debug, Clone)] pub enum DomainsAction { /// Start the shared local-domain proxy on port 80. Up, /// Stop the shared local-domain proxy. Down, /// List configured host -> target routes. List, /// Print the public URL for a configured localhost route. #[command(alias = "url")] Get(DomainsGetOpts), /// Add a localhost route (for example: linsa.localhost -> 127.0.0.1:3481). Add(DomainsAddOpts), /// Remove a localhost route. #[command(alias = "remove", alias = "delete")] Rm(DomainsRmOpts), /// Show proxy ownership, port 80 conflicts, and route summary. Doctor, } #[derive(Args, Debug, Clone)] pub struct DomainsAddOpts { /// Host name ending in .localhost. pub host: String, /// Upstream target in host:port format. pub target: String, /// Replace existing route target for this host. #[arg(long)] pub replace: bool, } #[derive(Args, Debug, Clone)] pub struct DomainsGetOpts { /// Host name ending in .localhost. pub host: String, /// Print the upstream host:port instead of the public URL. #[arg(long)] pub target: bool, } #[derive(Args, Debug, Clone)] pub struct DomainsRmOpts { /// Host name ending in .localhost. pub host: String, } #[derive(Args, Debug, Clone)] pub struct DaemonOpts { /// Address to bind the Axum server to. #[arg(long, default_value = "0.0.0.0")] pub host: IpAddr, /// TCP port for the daemon's HTTP interface. #[arg(long, default_value_t = 9050)] pub port: u16, /// Target FPS for the mock frame generator until a real screen capture backend lands. #[arg(long, default_value_t = 5, value_parser = clap::value_parser!(u8).range(1..=120))] pub fps: u8, /// Buffer size for the broadcast channel that fans screen frames out to connected clients. #[arg(long, default_value_t = 512)] pub frame_buffer: usize, /// Optional path to the flow config TOML (defaults to ~/.config/flow/config.toml). #[arg(long)] pub config: Option<PathBuf>, } #[derive(Args, Debug, Clone)] pub struct ScreenOpts { /// Number of frames to preview before exiting. #[arg(long, default_value_t = 10)] pub frames: u16, /// Frame generation rate for the preview stream. #[arg(long, default_value_t = 5, value_parser = clap::value_parser!(u8).range(1..=60))] pub fps: u8, /// How many frames we keep buffered locally while previewing. #[arg(long, default_value_t = 64)] pub frame_buffer: usize, } #[derive(Args, Debug, Clone)] pub struct LogsOpts { /// Hostname or IP address of the running flowd daemon. #[arg(long, default_value = "127.0.0.1")] pub host: IpAddr, /// TCP port of the daemon's HTTP interface. #[arg(long, default_value_t = 9050)] pub port: u16, /// Specific server to fetch logs for (omit to dump all servers). #[arg(long)] pub server: Option<String>, /// Number of log lines to fetch per server when not streaming. #[arg(long, default_value_t = 200)] pub limit: usize, /// Stream logs in real-time (requires --server). #[arg(long)] pub follow: bool, /// Disable ANSI color output in log prefixes. #[arg(long)] pub no_color: bool, } #[derive(Args, Debug, Clone)] pub struct TraceOpts { /// Show the last command's input/output instead of streaming events. #[arg(long)] pub last_command: bool, } #[derive(Args, Debug, Clone)] pub struct ServersOpts { /// Hostname or IP address of the running flowd daemon. #[arg(long, default_value = "127.0.0.1")] pub host: IpAddr, /// TCP port of the daemon's HTTP interface. #[arg(long, default_value_t = 9050)] pub port: u16, } #[derive(Args, Debug, Clone)] pub struct TasksCommand { #[command(subcommand)] pub action: Option<TasksAction>, } #[derive(Subcommand, Debug, Clone)] pub enum TasksAction { /// List tasks from the current project flow.toml. List(TasksListOpts), /// Show duplicate task names discovered across nested flow.toml files. Dupes(TasksDupesOpts), /// Initialize AI task directory with a MoonBit starter task. InitAi(TasksInitAiOpts), /// Prebuild and cache a specific AI task binary. BuildAi(TasksBuildAiOpts), /// Run a specific AI task with optional cache/daemon execution. RunAi(TasksRunAiOpts), /// Manage the AI task daemon. Daemon(TasksDaemonCommand), } #[derive(Args, Debug, Clone)] pub struct TasksListOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Show only duplicate task names and their scopes. #[arg(long)] pub dupes: bool, } #[derive(Args, Debug, Clone)] pub struct TasksDupesOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, } #[derive(Args, Debug, Clone)] pub struct TasksInitAiOpts { /// Root directory where .ai/tasks should be created. #[arg(long, default_value = ".")] pub root: PathBuf, /// Overwrite starter file if it already exists. #[arg(long)] pub force: bool, } #[derive(Args, Debug, Clone)] pub struct TasksBuildAiOpts { /// AI task selector (e.g. ai:flow/dev-check or flow/dev-check). #[arg(value_name = "TASK")] pub name: String, /// Root directory used for .ai/tasks discovery. #[arg(long, default_value = ".")] pub root: PathBuf, /// Force rebuild even if a cached artifact exists. #[arg(long)] pub force: bool, } #[derive(Args, Debug, Clone)] pub struct TasksRunAiOpts { /// AI task selector (e.g. ai:flow/dev-check or flow/dev-check). #[arg(value_name = "TASK")] pub name: String, /// Root directory used for .ai/tasks discovery. #[arg(long, default_value = ".")] pub root: PathBuf, /// Run through the AI task daemon. #[arg(long)] pub daemon: bool, /// Disable binary cache and use direct moon run. #[arg(long)] pub no_cache: bool, /// Additional arguments passed to the AI task. #[arg(value_name = "ARGS", trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct TasksDaemonCommand { #[command(subcommand)] pub action: TasksDaemonAction, } #[derive(Subcommand, Debug, Clone)] pub enum TasksDaemonAction { /// Start task daemon in the background. Start, /// Stop task daemon. Stop, /// Show task daemon status. Status, /// Run daemon server loop (internal). #[command(hide = true)] Serve, } #[derive(Args, Debug, Clone)] pub struct TasksOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, } impl Default for TasksOpts { fn default() -> Self { Self { config: PathBuf::from("flow.toml"), } } } #[derive(Args, Debug, Clone)] pub struct AiTestNewOpts { /// Name or relative path for the scratch test (e.g. auth-login, chat/loading-state). pub name: String, /// Base scratch test directory, relative to project root. #[arg(long, default_value = ".ai/test")] pub dir: String, /// Use `.spec.ts` instead of `.test.ts`. #[arg(long, default_value_t = false)] pub spec: bool, /// Overwrite existing file if present. #[arg(long, default_value_t = false)] pub force: bool, } #[derive(Args, Debug, Clone)] pub struct GlobalCommand { #[command(subcommand)] pub action: Option<GlobalAction>, /// Task name to run (omit to list global tasks). #[arg(value_name = "TASK")] pub task: Option<String>, /// List global tasks. #[arg(long, short)] pub list: bool, /// Additional arguments passed to the task command. #[arg(value_name = "ARGS", trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Subcommand, Debug, Clone)] pub enum GlobalAction { /// List global tasks. List, /// Run a global task by name. Run { /// Task name to run. #[arg(value_name = "TASK")] task: String, /// Additional arguments passed to the task command. #[arg(value_name = "ARGS", trailing_var_arg = true)] args: Vec<String>, }, /// Match a query against global tasks (LM Studio). Match(MatchOpts), } #[derive(Args, Debug, Clone)] pub struct TaskRunOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Hand off the task to the hub daemon instead of running it locally. #[arg(long)] pub delegate_to_hub: bool, /// Hub host to delegate tasks to (defaults to the local lin daemon). #[arg(long, default_value = "127.0.0.1")] pub hub_host: IpAddr, /// Hub port to delegate tasks to. #[arg(long, default_value_t = 9050)] pub hub_port: u16, /// Name of the task to execute. #[arg(value_name = "TASK")] pub name: String, /// Additional arguments passed to the task command. #[arg(value_name = "ARGS", trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct LifecycleRunOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Additional arguments passed to the lifecycle task. #[arg(value_name = "ARGS", trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct FastRunOpts { /// AI task selector (e.g. ai:flow/dev-check). #[arg(value_name = "TASK")] pub name: String, /// Root directory used for .ai/tasks discovery. #[arg(long, default_value = ".")] pub root: PathBuf, /// Disable binary cache and use direct moon run. #[arg(long)] pub no_cache: bool, /// Additional arguments passed to the AI task. #[arg(value_name = "ARGS", trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct TaskActivateOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, } #[derive(Args, Debug, Clone)] pub struct ProcessOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Show all running flow processes across all projects. #[arg(long)] pub all: bool, } #[derive(Args, Debug, Clone)] pub struct KillOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Kill by task name. #[arg(value_name = "TASK")] pub task: Option<String>, /// Kill by PID directly. #[arg(long)] pub pid: Option<u32>, /// Kill all processes for this project. #[arg(long)] pub all: bool, /// Force kill (SIGKILL) without graceful shutdown. #[arg(long, short)] pub force: bool, /// Timeout in seconds before sending SIGKILL (default: 5). #[arg(long, default_value_t = 5)] pub timeout: u64, } #[derive(Args, Debug, Clone)] pub struct TaskLogsOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Task name to view logs for. #[arg(value_name = "TASK")] pub task: Option<String>, /// Follow the log in real-time (like tail -f). #[arg(long, short)] pub follow: bool, /// Number of lines to show from the end. #[arg(long, short = 'n', default_value_t = 50)] pub lines: usize, /// Show logs for all projects. #[arg(long)] pub all: bool, /// List available log files instead of showing content. #[arg(long, short)] pub list: bool, /// Look up logs by registered project name instead of config path. #[arg(long, short)] pub project: Option<String>, /// Suppress headers, output only log content. #[arg(long, short)] pub quiet: bool, /// Hub task ID to fetch logs for (from delegated tasks). #[arg(long)] pub task_id: Option<String>, } #[derive(Args, Debug, Clone, Default)] pub struct DoctorOpts {} #[derive(Args, Debug, Clone)] pub struct HealthOpts {} #[derive(Args, Debug, Clone)] pub struct InvariantsOpts { /// Only check staged changes (default: check all changes vs HEAD). #[arg(long)] pub staged: bool, } #[derive(Args, Debug, Clone)] pub struct RerunOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, } #[derive(Args, Debug, Clone, Default)] pub struct ActiveOpts { /// Project name to set as active. #[arg(value_name = "PROJECT")] pub project: Option<String>, /// Clear the active project. #[arg(long, short)] pub clear: bool, } #[derive(Args, Debug, Clone, Default)] pub struct SessionsOpts { /// Filter by provider (claude, codex, cursor, or all). #[arg(long, short, default_value = "all")] pub provider: String, /// Number of exchanges to copy (default: all since checkpoint). #[arg(long, short)] pub count: Option<usize>, /// Show sessions but don't copy to clipboard. #[arg(long, short)] pub list: bool, /// Get full session context, ignoring checkpoints. #[arg(long, short)] pub full: bool, /// Generate summaries for stale sessions (uses Gemini). #[arg(long)] pub summarize: bool, /// Condense the selected session into a handoff summary (uses Gemini). #[arg(long)] pub handoff: bool, } #[derive(Args, Debug, Clone)] pub struct ServerOpts { /// Host to bind the server to. #[arg(long, default_value = "127.0.0.1")] pub host: String, /// Port for the HTTP server. #[arg(long, default_value_t = 9060)] pub port: u16, #[command(subcommand)] pub action: Option<ServerAction>, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum ServerAction { #[command(about = "Start the server in the foreground")] Foreground, #[command(about = "Stop the background server")] Stop, } #[derive(Args, Debug)] pub struct WebOpts { /// Port to serve the web UI on. #[arg(long, default_value_t = 9310)] pub port: u16, /// Host to bind the web UI server to. #[arg(long, default_value = "127.0.0.1")] pub host: String, } #[derive(Args, Debug, Clone)] pub struct InitOpts { /// Where to write the scaffolded flow.toml (defaults to ./flow.toml). #[arg(long)] pub path: Option<PathBuf>, } #[derive(Args, Debug, Clone)] pub struct ShellInitOpts { /// Shell to generate init script for (fish, zsh, bash). pub shell: String, } #[derive(Args, Debug, Clone)] pub struct ShellCommand { #[command(subcommand)] pub action: Option<ShellAction>, } #[derive(Subcommand, Debug, Clone)] pub enum ShellAction { /// Refresh the current shell session. Reset, /// Disable fish terminal query to avoid PDA warning. FixTerminal, } #[derive(Args, Debug, Clone)] pub struct NewOpts { /// Template name (e.g., web, docs). If omitted, shows fuzzy picker. pub template: Option<String>, /// Destination path. Plain names go to ~/code/ (e.g., "zerg" → ~/code/zerg). Use ./ for cwd. pub path: Option<String>, /// Show what would change without writing. #[arg(long)] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct HomeCommand { #[command(subcommand)] pub action: Option<HomeAction>, /// GitHub URL or owner/repo for the config repo. pub repo: Option<String>, /// Optional internal config repo URL (cloned into ~/config/i). #[arg(long)] pub internal: Option<String>, } #[derive(Subcommand, Debug, Clone)] pub enum HomeAction { /// Guide home setup and validate GitHub access. Setup, } #[derive(Args, Debug, Clone)] pub struct ArchiveOpts { /// Message to include in the archive folder name. pub message: String, } #[derive(Args, Debug, Clone)] pub struct HubCommand { #[command(subcommand)] pub action: Option<HubAction>, #[command(flatten)] pub opts: HubOpts, } #[derive(Args, Debug, Clone)] pub struct HubOpts { /// Hostname or IP address of the hub daemon. #[arg(long, default_value = "127.0.0.1", global = true)] pub host: IpAddr, /// TCP port for the daemon's HTTP interface. #[arg(long, default_value_t = 9050, global = true)] pub port: u16, /// Optional path to the lin hub config (defaults to lin's built-in lookup). #[arg(long, global = true)] pub config: Option<PathBuf>, /// Skip launching the hub TUI after ensuring the daemon is running. #[arg(long, global = true)] pub no_ui: bool, /// Also start the docs hub (Next.js dev server). #[arg(long, global = true)] pub docs_hub: bool, } #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum HubAction { #[command(about = "Start or ensure the hub daemon is running")] Start, #[command(about = "Stop the hub daemon if it was started by flow")] Stop, } #[derive(Args, Debug, Clone)] pub struct SecretsCommand { #[command(subcommand)] pub action: SecretsAction, } #[derive(Parser, Debug)] pub struct OtpCommand { #[command(subcommand)] pub action: OtpAction, } #[derive(Subcommand, Debug)] pub enum OtpAction { #[command(about = "Get a TOTP code from 1Password Connect")] Get { /// Vault name or id. vault: String, /// Item title or id. item: String, /// Optional field label to select when multiple TOTP fields exist. #[arg(long)] field: Option<String>, }, } #[derive(Subcommand, Debug, Clone)] pub enum SecretsAction { #[command(about = "List configured secret environments")] List(SecretsListOpts), #[command(about = "Fetch secrets for a specific environment")] Pull(SecretsPullOpts), } #[derive(Args, Debug, Clone)] pub struct SecretsListOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, } #[derive(Args, Debug, Clone)] pub struct SecretsPullOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Environment name defined in the secrets config. #[arg(value_name = "ENV")] pub env: String, /// Optional override for the secrets hub URL (default myflow.sh). #[arg(long)] pub hub: Option<String>, /// Optional file to write secrets to (defaults to stdout). #[arg(long)] pub output: Option<PathBuf>, /// Output format for rendered secrets. #[arg(long, default_value_t = SecretsFormat::Shell, value_enum)] pub format: SecretsFormat, } #[derive(Args, Debug, Clone)] pub struct DbCommand { #[command(subcommand)] pub action: DbAction, } #[derive(Subcommand, Debug, Clone)] pub enum DbAction { /// Jazz2 app credentials and env wiring. Jazz(JazzStorageCommand), /// Postgres workflows (migrations/generation). Postgres(PostgresCommand), } #[derive(Args, Debug, Clone)] pub struct JazzStorageCommand { #[command(subcommand)] pub action: JazzStorageAction, } #[derive(Subcommand, Debug, Clone)] pub enum JazzStorageAction { /// Create a new Jazz2 app credential set and store env vars. New { /// What the app credentials will be used for. #[arg(long, value_enum, default_value = "mirror")] kind: JazzStorageKind, /// Optional name for the app. #[arg(long)] name: Option<String>, /// Optional sync server URL (ws/wss urls are normalized to http/https). #[arg(long)] peer: Option<String>, /// Optional Jazz API key (for hosted cloud routing). #[arg(long)] api_key: Option<String>, /// Environment to store in (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, } #[derive(Debug, Clone, Copy, ValueEnum)] pub enum JazzStorageKind { /// Mirror app credentials (gitedit-style mirror sync). Mirror, /// Env store app credentials (cloud env store). EnvStore, /// App data app credentials (cloud app store). AppStore, } #[derive(Args, Debug, Clone)] pub struct PostgresCommand { #[command(subcommand)] pub action: PostgresAction, } #[derive(Subcommand, Debug, Clone)] pub enum PostgresAction { /// Generate Drizzle migrations for the configured Postgres project. Generate { /// Override the Postgres project directory (defaults to ~/org/la/la/server). #[arg(long)] project: Option<PathBuf>, }, /// Apply Drizzle migrations for the configured Postgres project. Migrate { /// Override the Postgres project directory (defaults to ~/org/la/la/server). #[arg(long)] project: Option<PathBuf>, /// Explicit DATABASE_URL (falls back to env/.env/Planetscale env vars). #[arg(long)] database_url: Option<String>, /// Generate migrations before applying them. #[arg(long, default_value_t = false)] generate: bool, }, } #[derive(Debug, Clone, Copy, ValueEnum)] pub enum SecretsFormat { Shell, Dotenv, } #[derive(Debug, Clone, Copy, ValueEnum)] pub enum SetupTarget { Deploy, Release, Docs, } #[derive(Args, Debug, Clone)] pub struct SetupOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Optional setup target (e.g., deploy). #[arg(value_enum, value_name = "TARGET")] pub target: Option<SetupTarget>, } #[derive(Args, Debug, Clone)] pub struct IndexOpts { /// Codanna binary to execute (defaults to looking up 'codanna' in PATH). #[arg(long, default_value = "codanna")] pub binary: String, /// Directory to index; defaults to the current working directory. #[arg(long)] pub project_root: Option<PathBuf>, /// SQLite destination for snapshots (defaults to ~/.db/flow/flow.sqlite). #[arg(long)] pub database: Option<PathBuf>, } #[derive(Args, Debug, Clone)] pub struct MatchOpts { /// Natural language query describing the task you want to run. #[arg(value_name = "QUERY", trailing_var_arg = true, num_args = 1..)] pub query: Vec<String>, /// LM Studio model to use (defaults to qwen3-8b). #[arg(long)] pub model: Option<String>, /// LM Studio API port (defaults to 1234). #[arg(long, default_value_t = 1234)] pub port: u16, /// Only show the match without running the task. #[arg(long, short = 'n')] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct AskOpts { /// Natural language query describing the task or command you want to run. #[arg(value_name = "QUERY", trailing_var_arg = true, num_args = 1..)] pub query: Vec<String>, /// AI server model to use (defaults to AI_SERVER_MODEL). #[arg(long)] pub model: Option<String>, /// AI server URL (defaults to AI_SERVER_URL or http://127.0.0.1:7331). #[arg(long)] pub url: Option<String>, } #[derive(Args, Debug, Clone)] pub struct BranchesCommand { #[command(subcommand)] pub action: Option<BranchesAction>, } #[derive(Subcommand, Debug, Clone)] pub enum BranchesAction { /// List recent branches. List(BranchListOpts), /// Find branches by substring or token query. Find(BranchFindOpts), /// Use AI to map a natural language query to the best branch. Ai(BranchAiOpts), } #[derive(Args, Debug, Clone)] pub struct BranchListOpts { /// Include remote branches. #[arg(long)] pub remote: bool, /// Maximum number of branches to show. #[arg(long, default_value_t = 40)] pub limit: usize, } #[derive(Args, Debug, Clone)] pub struct BranchFindOpts { /// Query text used to filter branch names and commit subjects. #[arg(value_name = "QUERY")] pub query: String, /// Include remote branches. #[arg(long)] pub remote: bool, /// Maximum number of matches to show. #[arg(long, default_value_t = 20)] pub limit: usize, /// Switch to the top match automatically. #[arg(long)] pub switch: bool, } #[derive(Args, Debug, Clone)] pub struct BranchAiOpts { /// Natural language query describing the branch you want. #[arg(value_name = "QUERY")] pub query: String, /// Include remote branches. #[arg(long)] pub remote: bool, /// Maximum candidate branches to send to AI. #[arg(long, default_value_t = 80)] pub limit: usize, /// AI server model to use (defaults to AI_SERVER_MODEL). #[arg(long)] pub model: Option<String>, /// AI server URL (defaults to AI_SERVER_URL or http://127.0.0.1:7331). #[arg(long)] pub url: Option<String>, /// Switch to the selected branch automatically. #[arg(long)] pub switch: bool, } #[derive(Args, Debug, Clone)] pub struct CommitOpts { /// Skip pushing after commit. #[arg(long, short = 'n')] pub no_push: bool, /// Queue the commit for review before pushing. #[arg(long)] pub queue: bool, /// Bypass commit queue and allow pushing immediately. #[arg(long, conflicts_with = "queue")] pub no_queue: bool, /// Force commit without queue (bypass stacked review). #[arg(long, conflicts_with = "queue")] pub force: bool, /// Commit and push immediately (bypass commit queue). #[arg(long, conflicts_with = "queue")] pub approved: bool, /// Open the queued commit in Rise for review after commit. #[arg(long)] pub review: bool, /// Run synchronously (don't delegate to hub). #[arg(long, visible_alias = "no-hub")] pub sync: bool, /// Include AI session context in code review (default: off). #[arg(long)] pub context: bool, /// Include an unhash.sh bundle/link in the commit message (opt-in). #[arg(long)] pub hashed: bool, /// Dry run: show context that would be passed to review without committing. #[arg(long)] pub dry: bool, /// Commit immediately and run Codex review asynchronously in the background. #[arg(long, conflicts_with = "dry")] pub quick: bool, /// Run blocking review before committing (legacy commitWithCheck behavior). #[arg(long, conflicts_with = "quick")] pub slow: bool, /// Use Codex instead of Claude for code review (default: Claude). #[arg(long)] pub codex: bool, /// Choose a specific review model (claude-opus, codex-high, codex-mini). #[arg(long, value_enum)] pub review_model: Option<ReviewModelArg>, /// Custom message to include in commit (appended after author line). #[arg(long, short = 'm')] pub message: Option<String>, /// Fast commit with optional message (defaults to "."). #[arg(long, value_name = "MESSAGE", num_args = 0..=1, default_missing_value = ".")] pub fast: Option<String>, /// Stage and commit only these paths (repeatable). #[arg(long = "path", value_name = "PATH")] pub paths: Vec<String>, /// Message to append after the AI-generated subject/body. #[arg(value_name = "MESSAGE", allow_hyphen_values = true)] pub message_arg: Option<String>, /// Max tokens for AI session context (default: 1000). #[arg(long, short = 't', default_value = "1000")] pub tokens: usize, /// Skip all quality gates for this commit. #[arg(long)] pub skip_quality: bool, /// Skip documentation requirements only. #[arg(long)] pub skip_docs: bool, /// Skip test requirements only. #[arg(long)] pub skip_tests: bool, } #[derive(Args, Debug, Clone)] pub struct PrOpts { /// Optional message to append to the AI-generated commit message, or subcommands like: /// f pr open /// f pr open edit /// f pr feedback [<number|url>] [--todo] #[arg(value_name = "ARGS", allow_hyphen_values = true)] pub args: Vec<String>, /// Base branch for the PR (default: main). #[arg(long, default_value = "main")] pub base: String, /// Create as a draft PR. #[arg(long)] pub draft: bool, /// Do not open the PR in browser after creating/finding it. #[arg(long)] pub no_open: bool, /// Skip creating a new commit; use an existing queued commit. #[arg(long)] pub no_commit: bool, /// Specific queued commit hash to use (short or full). #[arg(long)] pub hash: Option<String>, /// Stage and commit only these paths before creating PR (repeatable). #[arg(long = "path", value_name = "PATH")] pub paths: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct GitignoreCommand { #[command(subcommand)] pub action: Option<GitignoreAction>, } #[derive(Subcommand, Debug, Clone)] pub enum GitignoreAction { /// Audit .gitignore files for blocked personal-tooling patterns. Audit(GitignoreScanOpts), /// Remove blocked personal-tooling patterns from .gitignore files. Fix(GitignoreScanOpts), /// Create ~/.config/flow/gitignore-policy.toml with defaults. PolicyInit(GitignorePolicyInitOpts), /// Configure a global git excludes file with blocked personal-tooling patterns. SetupGlobal { /// Print target path/entries without writing changes. #[arg(long)] print_only: bool, }, /// Print the active policy file path. PolicyPath, } #[derive(Args, Debug, Clone)] pub struct GitignoreScanOpts { /// Root directory to scan for repositories (defaults to policy repos_roots, then ~/repos). #[arg(long)] pub root: Option<String>, /// Include repos owned by allowed owners. #[arg(long)] pub all: bool, } #[derive(Args, Debug, Clone)] pub struct GitignorePolicyInitOpts { /// Overwrite an existing policy file. #[arg(long)] pub force: bool, } #[derive(Args, Debug, Clone)] pub struct RecipeCommand { #[command(subcommand)] pub action: Option<RecipeAction>, } #[derive(Subcommand, Debug, Clone)] pub enum RecipeAction { /// List available recipes. List(RecipeListOpts), /// Search recipes by text query. Search(RecipeSearchOpts), /// Run a recipe by id or name. Run(RecipeRunOpts), /// Initialize recipe directories and starter files. Init(RecipeInitOpts), } #[derive(Args, Debug, Clone)] pub struct RecipeListOpts { /// Scope to include. #[arg(long, value_enum, default_value = "all")] pub scope: RecipeScopeArg, /// Optional text filter. #[arg(long)] pub query: Option<String>, /// Override global recipes directory. #[arg(long)] pub global_dir: Option<String>, } #[derive(Args, Debug, Clone)] pub struct RecipeSearchOpts { /// Search text. pub query: String, /// Scope to include. #[arg(long, value_enum, default_value = "all")] pub scope: RecipeScopeArg, /// Override global recipes directory. #[arg(long)] pub global_dir: Option<String>, } #[derive(Args, Debug, Clone)] pub struct RecipeRunOpts { /// Recipe id or name fragment. pub selector: String, /// Scope to include. #[arg(long, value_enum, default_value = "all")] pub scope: RecipeScopeArg, /// Override global recipes directory. #[arg(long)] pub global_dir: Option<String>, /// Working directory for execution. #[arg(long)] pub cwd: Option<String>, /// Print command without executing. #[arg(long)] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct RecipeInitOpts { /// Scope to initialize. #[arg(long, value_enum, default_value = "all")] pub scope: RecipeScopeArg, /// Override global recipes directory. #[arg(long)] pub global_dir: Option<String>, } #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum RecipeScopeArg { Project, Global, All, } #[derive(Args, Debug, Clone)] pub struct CommitQueueCommand { #[command(subcommand)] pub action: Option<CommitQueueAction>, } #[derive(Args, Debug, Clone)] pub struct ReviewCommand { #[command(subcommand)] pub action: Option<ReviewAction>, } #[derive(Args, Debug, Clone)] pub struct ReviewsTodoCommand { #[command(subcommand)] pub action: Option<ReviewsTodoAction>, } #[derive(Args, Debug, Clone)] pub struct GitRepairOpts { /// Branch to checkout if HEAD is detached (default: main). #[arg(long)] pub branch: Option<String>, /// Dry run - show what would be repaired. #[arg(long, short = 'n')] pub dry_run: bool, /// After repair, switch to target branch and cherry-pick current HEAD onto it. /// If conflicts occur, flow auto-aborts and returns to the source branch. #[arg(long)] pub land_main: bool, } #[derive(Subcommand, Debug, Clone)] pub enum CommitQueueAction { /// List queued commits. List, /// Show details for a queued commit. Show { /// Commit hash (short or full). hash: String, }, /// Open the queued commit diff in Rise app (multi-file diff UI). Open { /// Commit hash (short or full). hash: String, }, /// Print the full diff for a queued commit to stdout. Diff { /// Commit hash (short or full). hash: String, }, /// Re-run AI review for queued commits and refresh queue/todo metadata. Review { /// Commit hashes (short or full). If omitted, reviews current branch queue entries. hashes: Vec<String>, /// Review all queued commits across branches. #[arg(long)] all: bool, }, /// Approve a queued commit and push it. Approve { /// Approve all queued commits on the current branch (push once). #[arg(long)] all: bool, /// Commit hash (short or full). Defaults to HEAD when omitted. hash: Option<String>, /// If hash is not queued but exists in git history, queue it first. #[arg(long)] queue_if_missing: bool, /// Mark an auto-queued commit as manually reviewed. #[arg(long)] mark_reviewed: bool, /// Push even if the commit is not at HEAD. #[arg(long, short = 'f')] force: bool, /// Allow pushing even if the queued commit has review issues recorded. #[arg(long)] allow_issues: bool, /// Allow pushing even if the review timed out or is missing. #[arg(long)] allow_unreviewed: bool, }, /// Approve all queued commits on the current branch (push once). ApproveAll { /// Push even if the branch is behind its remote. #[arg(long, short = 'f')] force: bool, /// Allow pushing even if some queued commits have review issues recorded. #[arg(long)] allow_issues: bool, /// Allow pushing even if some queued commits have review timed out / missing. #[arg(long)] allow_unreviewed: bool, }, /// Remove a commit from the queue without pushing. Drop { /// Commit hash (short or full). hash: String, }, /// Create or update a GitHub PR for a queued commit (pushes a bookmark/branch as the PR head). PrCreate { /// Commit hash (short or full). hash: String, /// Base branch for the PR (default: main). #[arg(long, default_value = "main")] base: String, /// Create as a draft PR. #[arg(long)] draft: bool, /// Open PR in browser after creating/finding it. #[arg(long)] open: bool, }, /// Open the PR for a queued commit in the browser (creates it if missing). PrOpen { /// Commit hash (short or full). hash: String, /// Base branch for the PR if it needs to be created (default: main). #[arg(long, default_value = "main")] base: String, }, } #[derive(Subcommand, Debug, Clone)] pub enum ReviewAction { /// Open the latest queued commit in Rise. Latest, /// Copy a ready-to-send review prompt for a queued commit to clipboard. Copy { /// Commit hash (short or full). Defaults to latest queued commit. hash: Option<String>, }, } #[derive(Subcommand, Debug, Clone)] pub enum ReviewsTodoAction { /// List pending review todos with priority indicators. #[command(alias = "ls")] List, /// Show details for a review todo. Show { /// Todo id (short prefix or full). id: String, }, /// Mark a review todo as resolved. Done { /// Todo id (short prefix or full). id: String, }, /// Auto-fix a review todo via Codex. Fix { /// Todo id to fix. If omitted with --all, fixes all open review todos. id: Option<String>, /// Fix all open review todos. #[arg(long)] all: bool, }, /// Run Codex deep review for queued commits. Codex { /// Commit hashes (short or full). If omitted, reviews current branch queue entries. hashes: Vec<String>, /// Review all queued commits across branches. #[arg(long)] all: bool, }, /// Approve all queued commits once deep review todos are resolved. ApproveAll { /// Push even if the branch is behind its remote. #[arg(long, short = 'f')] force: bool, /// Allow pushing even if some queued commits have review issues recorded. #[arg(long)] allow_issues: bool, /// Allow pushing even if some queued commits have review timed out / missing. #[arg(long)] allow_unreviewed: bool, }, } #[derive(Args, Debug, Clone)] pub struct JjCommand { #[command(subcommand)] pub action: Option<JjAction>, } #[derive(Args, Debug, Clone, Default)] pub struct StatusOpts { /// Show raw `jj status` output without Flow's workflow summary. #[arg(long)] pub raw: bool, } #[derive(Args, Debug, Clone, Default)] pub struct JjStatusOpts { /// Show raw `jj status` output without Flow's workflow summary. #[arg(long)] pub raw: bool, } #[derive(Subcommand, Debug, Clone)] pub enum JjAction { /// Initialize jj in the repo (colocated with git when possible). Init { /// Optional path to initialize (defaults to current directory). #[arg(long)] path: Option<PathBuf>, }, /// Show jj status. Status(JjStatusOpts), /// Fetch from git remotes. Fetch, /// Rebase current change onto a destination. Rebase(JjRebaseOpts), /// Push bookmarks to git. Push(JjPushOpts), /// Fetch, rebase, then push a bookmark. Sync(JjSyncOpts), /// Manage workspaces. #[command(subcommand)] Workspace(JjWorkspaceAction), /// Manage bookmarks. #[command(subcommand)] Bookmark(JjBookmarkAction), } #[derive(Args, Debug, Clone)] pub struct JjRebaseOpts { /// Destination to rebase onto (default: jj.default_branch or main/master). #[arg(long)] pub dest: Option<String>, } #[derive(Args, Debug, Clone)] pub struct JjPushOpts { /// Bookmark to push. #[arg(long)] pub bookmark: Option<String>, /// Push all bookmarks. #[arg(long)] pub all: bool, } #[derive(Args, Debug, Clone)] pub struct JjSyncOpts { /// Bookmark to push after rebase (optional). #[arg(long)] pub bookmark: Option<String>, /// Destination to rebase onto (default: jj.default_branch or main/master). #[arg(long)] pub dest: Option<String>, /// Remote to sync with (default: git.remote, then jj.remote, then origin). #[arg(long)] pub remote: Option<String>, /// Skip pushing after rebase. #[arg(long)] pub no_push: bool, } #[derive(Subcommand, Debug, Clone)] pub enum JjWorkspaceAction { /// List workspaces. List, /// Add a workspace. Add { /// Workspace name. name: String, /// Optional path for workspace directory. #[arg(long)] path: Option<PathBuf>, /// Optional base revision for the new workspace working copy. #[arg(long)] rev: Option<String>, }, /// Create an isolated parallel workspace lane anchored on trunk. Lane { /// Lane/workspace name. name: String, /// Optional path for workspace directory. #[arg(long)] path: Option<PathBuf>, /// Base revision (default: <default_branch>@<remote> if tracked, else <default_branch>). #[arg(long)] base: Option<String>, /// Remote used for default base resolution. #[arg(long)] remote: Option<String>, /// Skip fetch before creating the lane. #[arg(long)] no_fetch: bool, }, /// Create or reuse a stable JJ workspace for a review branch without touching the current checkout. Review { /// Review branch name (for example: review/nikiv-feature). branch: String, /// Optional path for workspace directory. #[arg(long)] path: Option<PathBuf>, /// Optional base revision. Defaults to the branch commit when found, else trunk. #[arg(long)] base: Option<String>, /// Remote used for branch lookup and default base resolution. #[arg(long)] remote: Option<String>, /// Skip fetch before resolving the review branch. #[arg(long)] no_fetch: bool, }, } #[derive(Subcommand, Debug, Clone)] pub enum JjBookmarkAction { /// List bookmarks. List, /// Track a bookmark from a remote. Track { /// Bookmark name. name: String, /// Remote name (default: git.remote, then jj.remote, then origin). #[arg(long)] remote: Option<String>, }, /// Create a bookmark at a revision. Create { /// Bookmark name. name: String, /// Revision to attach to (default: @). #[arg(long)] rev: Option<String>, /// Whether to track the remote bookmark (default: jj.auto_track). #[arg(long)] track: Option<bool>, /// Remote to track (default: git.remote, then jj.remote, then origin). #[arg(long)] remote: Option<String>, }, } #[derive(Args, Debug, Clone)] pub struct FixupOpts { /// Path to the flow.toml to fix (defaults to ./flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Only show what would be fixed without making changes. #[arg(long, short = 'n')] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct FixOpts { /// Description of what to fix, or a path to a markdown fix report. #[arg(value_name = "MESSAGE", trailing_var_arg = true)] pub message: Vec<String>, /// Skip unrolling the last commit. #[arg(long)] pub no_unroll: bool, /// Stash local changes before unrolling, then restore after. #[arg(long)] pub stash: bool, /// Hive agent name to run (default: shell). #[arg(long, default_value = "shell")] pub agent: String, /// Skip running Hive agent (only unroll). #[arg(long)] pub no_agent: bool, } #[derive(Args, Debug, Clone)] pub struct UndoCommand { #[command(subcommand)] pub action: Option<UndoAction>, /// Dry run - show what would be undone without doing it. #[arg(long, short = 'n')] pub dry_run: bool, /// Force undo even if it requires force push. #[arg(long, short = 'f')] pub force: bool, } #[derive(Subcommand, Debug, Clone)] pub enum UndoAction { /// Show the last undoable action. Show, /// List recent undoable actions. List { /// Maximum number of actions to show. #[arg(short, long, default_value = "10")] limit: usize, }, } #[derive(Args, Debug, Clone)] pub struct ChangesCommand { #[command(subcommand)] pub action: Option<ChangesAction>, } #[derive(Args, Debug, Clone)] pub struct DiffCommand { /// Hash to unroll. When omitted, creates a new diff bundle. pub hash: Option<String>, /// Include specific env vars from local personal env store. /// Examples: --env CEREBRAS_API_KEY --env CEREBRAS_MODEL /// --env CEREBRAS_API_KEY,CEREBRAS_MODEL /// --env='[\"CEREBRAS_API_KEY\",\"CEREBRAS_MODEL\"]' #[arg(long, value_name = "KEY", action = clap::ArgAction::Append)] pub env: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct HashOpts { /// Arguments passed to unhash (paths or session flags). #[arg(trailing_var_arg = true, required = true)] pub args: Vec<String>, } #[derive(Subcommand, Debug, Clone)] pub enum ChangesAction { #[command( about = "Print the current git diff for sharing.", long_about = "Outputs git diff (including untracked files) so it can be applied elsewhere." )] CurrentDiff, #[command( about = "Apply a diff to the current repo.", long_about = "Accepts a diff string, a file path, or '-' to read from stdin." )] Accept { /// Diff content, '-' for stdin, or a path to a diff file. diff: Option<String>, /// Read diff from a file path. #[arg(short, long)] file: Option<PathBuf>, }, } #[derive(Args, Debug, Clone)] pub struct DaemonCommand { #[command(subcommand)] pub action: Option<DaemonAction>, } #[derive(Subcommand, Debug, Clone)] pub enum DaemonAction { /// Start a daemon by name. Start { /// Name of the daemon to start. name: String, }, /// Stop a running daemon. Stop { /// Name of the daemon to stop. name: String, }, /// Restart a daemon (stop then start). Restart { /// Name of the daemon to restart. name: String, }, /// Show status of all configured daemons. Status { /// Optional daemon name to filter status output. name: Option<String>, }, /// List available daemons. #[command(alias = "ls")] List, } #[derive(Args, Debug, Clone)] pub struct SupervisorCommand { #[command(subcommand)] pub action: Option<SupervisorAction>, /// Socket path for supervisor IPC (defaults to ~/.config/flow/supervisor.sock). #[arg(long, global = true)] pub socket: Option<PathBuf>, } #[derive(Subcommand, Debug, Clone)] pub enum SupervisorAction { /// Start the supervisor in the background. Start { /// Start boot daemons in addition to autostart daemons. #[arg(long)] boot: bool, }, /// Run the supervisor in the foreground (blocking). Run { /// Start boot daemons in addition to autostart daemons. #[arg(long)] boot: bool, }, /// Install a macOS LaunchAgent to keep the supervisor running. Install { /// Start boot daemons in addition to autostart daemons. #[arg(long)] boot: bool, }, /// Remove the macOS LaunchAgent for the supervisor. Uninstall, /// Stop the supervisor if running. Stop, /// Show supervisor status. Status, } #[derive(Args, Debug, Clone)] pub struct AiCommand { #[command(subcommand)] pub action: Option<AiAction>, } #[derive(Subcommand, Debug, Clone)] pub enum AiAction { /// List all AI sessions for this project (Claude + Codex + Cursor). #[command(alias = "ls")] List, /// Cursor: inspect and read agent transcripts for this project. Cursor { #[command(subcommand)] action: Option<ProviderAiAction>, }, /// Claude Code: continue last session or start new one. Claude { #[command(subcommand)] action: Option<ProviderAiAction>, }, /// Codex: continue last session or start new one. Codex { #[command(subcommand)] action: Option<ProviderAiAction>, }, /// Run a prompt through Everruns and bridge client-side tool calls to seqd. #[command(alias = "er")] Everruns(AiEverrunsOpts), /// Resume an AI session by name or ID. Resume { /// Session name or ID to resume. #[arg(value_name = "SESSION")] session: Option<String>, /// Project path to resume from instead of the current directory. #[arg(long)] path: Option<String>, }, /// Save/bookmark the current or most recent session with a name. Save { /// Name for the session. name: String, /// Session ID to save (defaults to most recent). #[arg(long)] id: Option<String>, }, /// Open or create notes for a session. Notes { /// Session name or ID. session: String, }, /// Remove a saved session from tracking (doesn't delete the actual session). Remove { /// Session name or ID to remove. session: String, }, /// Initialize .ai folder structure in current project. Init, /// Import all existing sessions for this project. Import, /// Copy session history to clipboard (fuzzy search to select). Copy { /// Session name or ID to copy (if not provided, shows fuzzy search). session: Option<String>, }, /// Copy last Claude session to clipboard. Optionally search for a session containing text. #[command(name = "copy-claude", alias = "cc")] CopyClaude { /// Search for a session containing this text. #[arg(value_name = "SEARCH", trailing_var_arg = true)] search: Vec<String>, }, /// Copy last Codex session to clipboard. Optionally search for a session containing text. #[command(name = "copy-codex", alias = "cx")] CopyCodex { /// Search for a session containing this text. #[arg(value_name = "SEARCH", trailing_var_arg = true)] search: Vec<String>, }, /// Copy last prompt and response from a session to clipboard (for context passing). /// Usage: f ai context [session] [path] [count] Context { /// Session name or ID (if not provided, shows fuzzy search). session: Option<String>, /// Path to project directory (default: current directory). path: Option<String>, /// Number of exchanges to include (default: 1). #[arg(default_value = "1")] count: usize, }, } #[derive(Args, Debug, Clone)] pub struct AiEverrunsOpts { /// Prompt to send as a user message. #[arg(value_name = "PROMPT", trailing_var_arg = true)] pub prompt: Vec<String>, /// Reuse an existing Everruns session ID. #[arg(long)] pub session_id: Option<String>, /// Agent ID to use when creating a new session. #[arg(long)] pub agent_id: Option<String>, /// Harness ID to use when creating a new session. #[arg(long)] pub harness_id: Option<String>, /// Model ID override when creating a new session. #[arg(long)] pub model_id: Option<String>, /// Everruns API base URL (default: http://127.0.0.1:9300/api). #[arg(long)] pub base_url: Option<String>, /// Everruns API key (Bearer token). Prefer env var when possible. #[arg(long)] pub api_key: Option<String>, /// Poll interval for /events while waiting for completion. #[arg(long, default_value_t = 250)] pub poll_ms: u64, /// Max seconds to wait for output/tool cycles before timing out. #[arg(long, default_value_t = 120)] pub wait_timeout_secs: u64, /// Path to seqd Unix socket (default: $SEQ_SOCKET_PATH, then /tmp/seqd.sock). #[arg(long)] pub seq_socket: Option<PathBuf>, /// Read/write timeout for seqd RPC calls in milliseconds. #[arg(long, default_value_t = 5000)] pub seq_timeout_ms: u64, /// Do not inject seq client-side tool definitions when creating a new session. #[arg(long)] pub no_seq_tools: bool, } /// Provider-specific AI actions (for claude/codex subcommands). #[derive(Subcommand, Debug, Clone)] pub enum ProviderAiAction { /// List sessions for this provider. #[command(alias = "ls")] List, /// Print the most recent session ID for this provider. #[command(name = "latest-id", alias = "latest")] LatestId { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, }, /// List provider sessions with IDs. #[command(alias = "sess")] Sessions { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Continue the most recent session for this provider. Continue { /// Session name or ID to continue (optional). #[arg(value_name = "SESSION")] session: Option<String>, /// Project path to continue from instead of the current directory. #[arg(long)] path: Option<String>, }, /// Start a new session (ignores existing sessions). New, /// Resume a session. Resume { /// Session name or ID to resume. #[arg(value_name = "SESSION")] session: Option<String>, /// Project path to resume from instead of the current directory. #[arg(long)] path: Option<String>, }, /// Connect to an existing Codex session selected by natural-language query. #[command(alias = "home")] Connect { /// Project path or repo root to search instead of the configured Codex home-session path. #[arg(long, alias = "repo")] path: Option<String>, /// Restrict --path lookup to an exact cwd match instead of a repo-tree prefix. #[arg(long, requires = "path")] exact_cwd: bool, /// Emit machine-readable JSON for the selected session instead of resuming it. #[arg(long, alias = "print")] json: bool, /// Natural-language query describing the target session. Defaults to the latest session. #[arg(value_name = "QUERY", trailing_var_arg = true)] query: Vec<String>, }, /// Open a Codex session with fast repo-scoped recovery and reference unrolling. Open { /// Project path to open from instead of the current directory. #[arg(long)] path: Option<String>, /// Restrict session lookup to an exact cwd match instead of a repo-tree prefix. #[arg(long, requires = "path")] exact_cwd: bool, /// Query or initial prompt. #[arg(value_name = "QUERY", trailing_var_arg = true)] query: Vec<String>, }, /// Resolve how `f codex open` would interpret a query. Resolve { /// Project path to resolve from instead of the current directory. #[arg(long)] path: Option<String>, /// Restrict session lookup to an exact cwd match instead of a repo-tree prefix. #[arg(long, requires = "path")] exact_cwd: bool, /// Emit machine-readable JSON. #[arg(long)] json: bool, /// Query or reference text to resolve. #[arg(value_name = "QUERY", trailing_var_arg = true)] query: Vec<String>, }, /// Print effective Codex control-plane settings for this path. Doctor { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Exit non-zero unless wrapper transport and runtime skills are active. #[arg(long)] assert_runtime: bool, /// Exit non-zero unless the scheduled scorecard refresher is installed and loaded. #[arg(long)] assert_schedule: bool, /// Exit non-zero unless Flow has grounded learning data for this target. #[arg(long)] assert_learning: bool, /// Exit non-zero unless runtime, schedule, and grounded learning are all active. #[arg(long)] assert_autonomous: bool, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Evaluate how well Flow-guided Codex usage is working for this repo/path. Eval { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Maximum number of recent logged events/outcomes to inspect. #[arg(long, default_value = "200")] limit: usize, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Record a fast-path Codex launch and ensure supporting daemons are warm (internal). #[command(hide = true, name = "touch-launch")] TouchLaunch { /// Quick launch mode. #[arg(long, value_parser = ["resume-last", "new"])] mode: String, /// Working directory used for the launch. #[arg(long)] cwd: Option<String>, }, /// Enable the global Codex wrapper/runtime path so Flow features are actually used. #[command(name = "enable-global")] EnableGlobal { /// Show the resulting global config and actions without writing anything. #[arg(long)] dry_run: bool, /// Also install the macOS launchd scorecard refresher. #[arg(long)] install_launchd: bool, /// Start codexd immediately after enabling the global config. #[arg(long)] start_daemon: bool, /// Sync any discovered external skill sources after enabling the config. #[arg(long)] sync_skills: bool, /// Shortcut for --install-launchd --start-daemon --sync-skills. #[arg(long)] full: bool, /// Launchd cadence in minutes (used with --install-launchd/--full). #[arg(long, default_value = "30")] minutes: usize, /// Max logged events to scan per launchd run. #[arg(long, default_value = "400")] limit: usize, /// Max repos to rebuild per launchd run. #[arg(long, default_value = "12")] max_targets: usize, /// Recent-history window for launchd cron selection. #[arg(long, default_value = "168")] within_hours: u64, }, /// Manage the Flow codexd query daemon. Daemon { #[command(subcommand)] action: Option<CodexDaemonAction>, }, /// Inspect or sync the Jazz2-backed Codex memory mirror. Memory { #[command(subcommand)] action: Option<CodexMemoryAction>, }, /// Export redacted Codex workflow telemetry to configured Maple endpoints. Telemetry { #[command(subcommand)] action: Option<CodexTelemetryAction>, }, /// Inspect Flow-managed Codex traces for the current or a specific session. Trace { #[command(subcommand)] action: Option<CodexTraceAction>, }, /// Build and inspect local Codex skill scorecards from Flow history. #[command(name = "skill-eval")] SkillEval { #[command(subcommand)] action: Option<CodexSkillEvalAction>, }, /// Discover and sync external Codex skill sources. #[command(name = "skill-source")] SkillSource { #[command(subcommand)] action: Option<CodexSkillSourceAction>, }, /// Inspect or manage Flow-managed Codex runtime helpers. Runtime { #[command(subcommand)] action: Option<CodexRuntimeAction>, }, /// Search Codex sessions by prompt text and resume the best match. #[command(alias = "search")] Find { /// Limit search to sessions from this path or repo subtree (default: all Codex sessions). #[arg(long)] path: Option<String>, /// Restrict --path lookup to an exact cwd instead of a repo-tree prefix. #[arg(long, requires = "path")] exact_cwd: bool, /// Prompt or transcript text to search for. #[arg(value_name = "QUERY", trailing_var_arg = true)] query: Vec<String>, }, /// Search Codex sessions by prompt text and copy the best match to clipboard. #[command(name = "findAndCopy", alias = "find-and-copy", alias = "find-copy")] FindAndCopy { /// Limit search to sessions from this path or repo subtree (default: all Codex sessions). #[arg(long)] path: Option<String>, /// Restrict --path lookup to an exact cwd instead of a repo-tree prefix. #[arg(long, requires = "path")] exact_cwd: bool, /// Prompt or transcript text to search for. #[arg(value_name = "QUERY", trailing_var_arg = true)] query: Vec<String>, }, /// Copy session history to clipboard. Copy { /// Session name or ID to copy. session: Option<String>, }, /// Copy last prompt and response to clipboard (for context passing). /// Usage: f ai claude context [session] [path] [count] Context { /// Session name or ID to copy. session: Option<String>, /// Path to project directory (default: current directory). path: Option<String>, /// Number of exchanges to include (default: 1). #[arg(default_value = "1")] count: usize, }, /// Print a cleaned session excerpt to stdout. Show { /// Session name or ID to print. Defaults to the latest session for --path/current dir. session: Option<String>, /// Path to project directory (default: current directory). #[arg(long)] path: Option<String>, /// Number of exchanges to include (default: 12). #[arg(long, default_value = "12", conflicts_with = "full")] count: usize, /// Print the full cleaned transcript instead of just the trailing exchanges. #[arg(long)] full: bool, }, /// Recover recent Codex session context for a repo or subpath. Recover { /// Path to recover context for (default: current directory). #[arg(long)] path: Option<String>, /// Restrict lookup to an exact cwd match instead of a repo-tree prefix. #[arg(long)] exact_cwd: bool, /// Maximum number of candidate sessions to return. #[arg(long, default_value = "3")] limit: usize, /// Emit machine-readable JSON. #[arg(long, conflicts_with = "summary_only")] json: bool, /// Emit only the compact recovery summary for prompt injection. #[arg(long = "summary-only", conflicts_with = "json")] summary_only: bool, /// Optional query used to rank recent sessions. #[arg(value_name = "QUERY", trailing_var_arg = true)] query: Vec<String>, }, } #[derive(Subcommand, Debug, Clone)] pub enum CodexDaemonAction { /// Start codexd under Flow supervision. Start, /// Stop codexd. Stop, /// Restart codexd. Restart, /// Show codexd status. Status, /// Run codexd in the foreground (internal). #[command(hide = true)] Serve { /// Override the codexd socket path. #[arg(long)] socket: Option<PathBuf>, }, /// Ping codexd and exit non-zero if unavailable (internal). #[command(hide = true)] Ping, } #[derive(Subcommand, Debug, Clone)] pub enum CodexMemoryAction { /// Show memory mirror status and counts. Status { /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Sync recent Codex skill-eval logs into the Jazz2-backed memory mirror. Sync { /// Maximum number of recent events and outcomes to ingest. #[arg(long, default_value = "400")] limit: usize, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Query compact repo/code memory facts for a path. Query { /// Project path or repo root to query. #[arg(long)] path: Option<String>, /// Maximum number of fact hits to include. #[arg(long, default_value = "6")] limit: usize, /// Emit machine-readable JSON. #[arg(long)] json: bool, /// Query text to rank facts. #[arg(value_name = "QUERY", trailing_var_arg = true)] query: Vec<String>, }, /// Show recent memory rows, optionally scoped to a repo/path. Recent { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Maximum number of rows to print. #[arg(long, default_value = "12")] limit: usize, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, } #[derive(Subcommand, Debug, Clone)] pub enum CodexTelemetryAction { /// Show Codex telemetry export config and current forwarder state. Status { /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Flush recently logged Codex telemetry to configured Maple endpoints once. Flush { /// Maximum number of unseen events/outcomes to export in one pass. #[arg(long, default_value = "200")] limit: usize, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, } #[derive(Subcommand, Debug, Clone)] pub enum CodexTraceAction { /// Show Maple trace read status and configured credentials. Status { /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Inspect the trace associated with the active Flow-managed Codex session. #[command(name = "current-session")] CurrentSession { /// Flush recent Flow Codex telemetry before inspecting the trace. #[arg(long, default_value_t = true)] flush: bool, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Inspect a specific trace id. Inspect { /// Trace id to inspect. trace_id: String, /// Flush recent Flow Codex telemetry before inspecting the trace. #[arg(long, default_value_t = true)] flush: bool, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, } #[derive(Subcommand, Debug, Clone)] pub enum CodexSkillEvalAction { /// Rebuild the local scorecard for this repo/path from recent Flow Codex history. Run { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Maximum number of recent events to use when rebuilding. #[arg(long, default_value = "200")] limit: usize, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Show the current scorecard for this repo/path. Show { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Show recent logged skill-eval events. Events { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Maximum number of events to print. #[arg(long, default_value = "12")] limit: usize, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Refresh scorecards for the most recent repos seen in Flow Codex history. Cron { /// Maximum number of logged events to scan for target repos. #[arg(long, default_value = "400")] limit: usize, /// Maximum number of repo targets to rebuild in one pass. #[arg(long, default_value = "12")] max_targets: usize, /// Only consider repos seen within this many recent hours. #[arg(long, default_value = "168")] within_hours: u64, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, } #[derive(Subcommand, Debug, Clone)] pub enum CodexSkillSourceAction { /// List discovered external skills available for Codex runtime injection. List { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Copy discovered external skills into ~/.codex/skills for persistent use. Sync { /// Project path to inspect instead of the current directory. #[arg(long)] path: Option<String>, /// Restrict sync to the named discovered skills. #[arg(long = "skill")] skills: Vec<String>, /// Overwrite an existing ~/.codex/skills/<name> directory. #[arg(long)] force: bool, }, } #[derive(Subcommand, Debug, Clone)] pub enum CodexRuntimeAction { /// Show recent Flow-managed Codex runtime skill activations. Show, /// Remove Flow-managed runtime skill state and stale symlinks. Clear, /// Write a markdown plan to ~/plan and print the final path. WritePlan { /// Human-readable title used to derive the filename. #[arg(long)] title: Option<String>, /// Explicit filename stem to use instead of deriving from the title. #[arg(long)] stem: Option<String>, /// Destination directory (defaults to ~/plan). #[arg(long)] dir: Option<String>, /// Codex session id to append as a footer (defaults to $CODEX_THREAD_ID). #[arg(long)] source_session: Option<String>, }, } #[derive(Args, Debug, Clone)] pub struct EnvCommand { #[command(subcommand)] pub action: Option<EnvAction>, } #[derive(Args, Debug, Clone)] pub struct AuthOpts { /// Override API base URL for myflow (defaults to https://myflow.sh). #[arg(long)] pub api_url: Option<String>, } #[derive(Args, Debug, Clone)] pub struct ServicesCommand { #[command(subcommand)] pub action: Option<ServicesAction>, } #[derive(Args, Debug, Clone)] pub struct PushCommand { /// Git remote name to push to (default: origin). #[arg(long, default_value = "origin")] pub remote: String, /// Owner/org for the mirror repo (overrides FLOW_PUSH_OWNER / personal env store). #[arg(long)] pub owner: Option<String>, /// Override repo name (defaults to upstream/origin repo name or folder name). #[arg(long)] pub repo: Option<String>, /// Create the target repo if it does not exist (requires `gh` auth). #[arg(long)] pub create_repo: bool, /// Overwrite an existing remote URL when it points elsewhere. #[arg(long)] pub force: bool, /// Do not attempt to unlock Flow-managed SSH key before pushing. #[arg(long)] pub no_ssh: bool, /// TTL (hours) for Flow SSH key unlock (default: 24). #[arg(long, default_value = "24")] pub ttl_hours: u64, /// Print what would be done without changing remotes or pushing. #[arg(long)] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct SshCommand { #[command(subcommand)] pub action: Option<SshAction>, } #[derive(Subcommand, Debug, Clone)] pub enum EnvAction { /// Sync project settings and set up autonomous agent workflow. Sync, /// Unlock env read access (Touch ID on macOS). Unlock, /// Create a new env token from available templates. New, /// Authenticate with cloud to fetch env vars. Login, /// Fetch env vars from cloud and write to .env. Pull { /// Environment to fetch (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, /// Push local .env to cloud. Push { /// Environment to push to (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, /// Guided prompt to set required env vars from flow.toml. Guide { /// Environment to set in (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, /// Apply env vars from cloud to the configured Cloudflare worker. Apply, /// Bootstrap Cloudflare secrets from flow.toml (interactive). Bootstrap, /// Interactive env setup (uses flow.toml when configured). Setup { /// Optional .env file path to preselect. #[arg(short = 'f', long)] env_file: Option<PathBuf>, /// Optional environment to preselect. #[arg(short, long)] environment: Option<String>, }, /// List env vars for this project. #[command(alias = "ls")] List { /// Environment to list (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, /// Set a personal env var (default backend). Set { /// KEY=VALUE pair to set. pair: String, /// Compatibility flag (ignored; set always targets personal env). #[arg(long)] personal: bool, }, /// Delete personal env var(s). Delete { /// Key(s) to delete. keys: Vec<String>, }, /// Manage project-scoped env vars. Project { #[command(subcommand)] action: ProjectEnvAction, }, /// Show current auth status. Status, /// Get specific env var(s) and print to stdout. Get { /// Key(s) to fetch. keys: Vec<String>, /// Fetch from personal env vars instead of project. #[arg(long)] personal: bool, /// Environment to fetch from (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, /// Output format: env (KEY=VALUE), json, or value (just the value, single key only). #[arg(short, long, default_value = "env")] format: String, }, /// Run a command with env vars injected from cloud. Run { /// Fetch from personal env vars instead of project. #[arg(long)] personal: bool, /// Environment to fetch from (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, /// Specific keys to inject (if empty, injects all). #[arg(long, short = 'k')] keys: Vec<String>, /// Command and arguments to run. #[arg(trailing_var_arg = true, required = true)] command: Vec<String>, }, /// Show configured env keys from flow.toml. Keys, /// Manage service tokens for host deployments. Token { #[command(subcommand)] action: TokenAction, }, } #[derive(Subcommand, Debug, Clone)] pub enum ServicesAction { /// Set up Stripe env vars with guided prompts. Stripe(StripeServiceOpts), /// List available service setup flows. #[command(alias = "ls")] List, } #[derive(Args, Debug, Clone)] pub struct MacosCommand { #[command(subcommand)] pub action: Option<MacosAction>, } #[derive(Subcommand, Debug, Clone)] pub enum MacosAction { /// List all launchd services. #[command(alias = "ls")] List(MacosListOpts), /// Show running non-Apple services. Status, /// Audit services with recommendations. Audit(MacosAuditOpts), /// Show detailed info about a service. Info(MacosInfoOpts), /// Disable a service. Disable(MacosDisableOpts), /// Enable a service. Enable(MacosEnableOpts), /// Disable known bloatware services. Clean(MacosCleanOpts), } #[derive(Args, Debug, Clone)] pub struct MacosListOpts { /// Only show user agents. #[arg(long)] pub user: bool, /// Only show system agents/daemons. #[arg(long)] pub system: bool, /// Output as JSON. #[arg(long)] pub json: bool, } #[derive(Args, Debug, Clone)] pub struct MacosAuditOpts { /// Output as JSON. #[arg(long)] pub json: bool, } #[derive(Args, Debug, Clone)] pub struct MacosInfoOpts { /// Service identifier (e.g., com.google.keystone.agent). pub service: String, } #[derive(Args, Debug, Clone)] pub struct MacosDisableOpts { /// Service identifier to disable. pub service: String, /// Skip confirmation prompt. #[arg(short = 'y', long)] pub yes: bool, } #[derive(Args, Debug, Clone)] pub struct MacosEnableOpts { /// Service identifier to enable. pub service: String, } #[derive(Args, Debug, Clone)] pub struct MacosCleanOpts { /// Only show what would be done. #[arg(long)] pub dry_run: bool, /// Skip confirmation prompt. #[arg(short = 'y', long)] pub yes: bool, } #[derive(Args, Debug, Clone)] pub struct StripeServiceOpts { /// Path to the project root (defaults to current directory). #[arg(short, long)] pub path: Option<PathBuf>, /// Environment to store vars in (dev, staging, production). #[arg(short, long)] pub environment: Option<String>, /// Stripe mode (test or live). #[arg(long, value_enum, default_value_t = StripeModeArg::Test)] pub mode: StripeModeArg, /// Prompt even if keys are already set. #[arg(long)] pub force: bool, /// Apply env vars to Cloudflare after setting them. #[arg(long, conflicts_with = "no_apply")] pub apply: bool, /// Skip applying env vars to Cloudflare. #[arg(long, conflicts_with = "apply")] pub no_apply: bool, } #[derive(ValueEnum, Debug, Clone, Copy)] pub enum StripeModeArg { Test, Live, } #[derive(Subcommand, Debug, Clone)] pub enum SshAction { /// Generate a new SSH keypair and store it in cloud personal env vars. Setup { /// Optional key name (default: "default"). #[arg(long, default_value = "default")] name: String, /// Skip automatically unlocking the key after setup. #[arg(long)] no_unlock: bool, }, /// Unlock the SSH key from cloud and load it into the Flow SSH agent. Unlock { /// Optional key name (default: "default"). #[arg(long, default_value = "default")] name: String, /// TTL for ssh-agent in hours (default: 24). #[arg(long, default_value = "24")] ttl_hours: u64, }, /// Show whether the Flow SSH agent and key are available. Status { /// Optional key name (default: "default"). #[arg(long, default_value = "default")] name: String, }, } #[derive(Subcommand, Debug, Clone)] pub enum TokenAction { /// Create a new service token for a project. Create { /// Token name (e.g., "pulse-production"). #[arg(short, long)] name: Option<String>, /// Permissions: read, write, or admin. #[arg(short, long, default_value = "read")] permissions: String, }, /// List service tokens. #[command(alias = "ls")] List, /// Revoke a service token. Revoke { /// Token name to revoke. name: String, }, } #[derive(Subcommand, Debug, Clone)] pub enum ProjectEnvAction { /// Set a project-scoped env var. Set { /// KEY=VALUE pair to set. pair: String, /// Environment (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, /// Delete project-scoped env var(s). Delete { /// Key(s) to delete. keys: Vec<String>, /// Environment (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, /// List project env vars. #[command(alias = "ls")] List { /// Environment (dev, staging, production). #[arg(short, long, default_value = "production")] environment: String, }, } #[derive(Args, Debug, Clone)] pub struct TodoCommand { #[command(subcommand)] pub action: Option<TodoAction>, } #[derive(Args, Debug, Clone)] pub struct ExtCommand { /// Path to the external directory to move. pub path: String, } #[derive(Subcommand, Debug, Clone)] pub enum TodoAction { /// Open the project Bike file. Bike, /// Add a new todo. Add { /// Short title for the todo. title: String, /// Optional note to store with the todo. #[arg(short, long)] note: Option<String>, /// Attach a specific AI session reference (provider:session_id). #[arg(long, conflicts_with = "no_session")] session: Option<String>, /// Skip attaching the most recent AI session. #[arg(long)] no_session: bool, /// Initial status (pending, in-progress, completed, blocked). #[arg(short, long, value_enum, default_value_t = TodoStatusArg::Pending)] status: TodoStatusArg, }, /// List todos (active by default). #[command(alias = "ls")] List { /// Include completed todos. #[arg(long)] all: bool, }, /// Mark a todo as completed. Done { /// Todo id (full or prefix). id: String, }, /// Edit a todo. Edit { /// Todo id (full or prefix). id: String, /// Update the title. #[arg(short, long)] title: Option<String>, /// Update the status. #[arg(short, long, value_enum)] status: Option<TodoStatusArg>, /// Update the note (empty clears). #[arg(short, long)] note: Option<String>, }, /// Remove a todo. Remove { /// Todo id (full or prefix). id: String, }, } #[derive(ValueEnum, Debug, Clone, Copy)] pub enum TodoStatusArg { Pending, #[value(alias = "in_progress")] InProgress, Completed, Blocked, } #[derive(Args, Debug, Clone)] pub struct DepsCommand { #[command(subcommand)] pub action: Option<DepsAction>, /// Force a package manager instead of auto-detect. #[arg(long, value_enum)] pub manager: Option<DepsManager>, } #[derive(Subcommand, Debug, Clone)] pub enum DepsAction { /// Install dependencies. Install { /// Extra args to pass to the package manager. #[arg(trailing_var_arg = true)] args: Vec<String>, }, /// Smart dependency updates based on inferred ecosystem. Update(UpdateDepsOpts), /// Fuzzy-pick a dependency or linked repo and fetch it to ~/repos. #[command(alias = "pick", alias = "find", alias = "search")] Pick, /// Add an external repo dependency and link it under .ai/repos. Repo { /// Repository URL, owner/repo, or repo name (searches ~/repos). repo: String, /// Root directory for clones (default: ~/repos). #[arg(long, default_value = "~/repos")] root: String, /// Create a private fork in your GitHub account and set origin. #[arg(long, alias = "private-origin")] private: bool, }, } #[derive(ValueEnum, Debug, Clone, Copy)] pub enum DepsManager { Pnpm, Npm, Yarn, Bun, } #[derive(ValueEnum, Debug, Clone, Copy, Eq, PartialEq)] pub enum DepsEcosystem { Js, Rust, Go, } #[derive(Args, Debug, Clone, Default)] pub struct UpdateDepsOpts { /// Upgrade to latest versions when supported by ecosystem tooling. #[arg(long)] pub latest: bool, /// Print planned commands without executing them. #[arg(long)] pub dry_run: bool, /// Skip confirmation prompt. #[arg(short = 'y', long)] pub yes: bool, /// Disable OpenTUI confirmation and use plain prompt. #[arg(long)] pub no_tui: bool, /// Force a specific ecosystem instead of auto-detect. #[arg(long, value_enum)] pub ecosystem: Option<DepsEcosystem>, /// Force JS package manager (only used for js ecosystem). #[arg(long, value_enum)] pub manager: Option<DepsManager>, /// Extra arguments passed through to ecosystem update commands. #[arg(trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct SkillsCommand { #[command(subcommand)] pub action: Option<SkillsAction>, } #[derive(Subcommand, Debug, Clone)] pub enum SkillsAction { /// List all skills for this project. #[command(alias = "ls")] List, /// Create a new skill. New { /// Skill name (kebab-case recommended). name: String, /// Short description of what the skill does. #[arg(short, long)] description: Option<String>, }, /// Show skill details. Show { /// Skill name. name: String, }, /// Edit a skill in your editor. Edit { /// Skill name. name: String, }, /// Remove a skill. Remove { /// Skill name. name: String, }, /// Install a curated skill from the registry. Install { /// Skill name to install. name: String, }, /// Publish a local skill to the shared registry. Publish { /// Skill name to publish. name: String, }, /// Search for skills in the remote registry. Search { /// Search query (optional). query: Option<String>, }, /// Sync flow.toml tasks as skills. Sync, /// Force Codex app-server to rescan skills from disk for this cwd. Reload, /// Fetch dependency skills via seq scraper integration. Fetch(SkillsFetchCommand), } #[derive(Args, Debug, Clone)] pub struct SkillsFetchCommand { #[command(subcommand)] pub action: SkillsFetchAction, /// Path to seq repo (default: ~/code/seq). #[arg(long)] pub seq_repo: Option<String>, /// Path to seq teach script (overrides --seq-repo). #[arg(long)] pub script_path: Option<String>, /// Scraper daemon/API base URL. #[arg(long)] pub scraper_base_url: Option<String>, /// Scraper API token. #[arg(long)] pub scraper_api_key: Option<String>, /// Output directory for generated skills (relative to repo root). #[arg(long)] pub out_dir: Option<String>, /// Cache TTL in hours for scraper responses. #[arg(long)] pub cache_ttl_hours: Option<f64>, /// Allow direct fetch fallback when scraper queue/api is unavailable. #[arg(long)] pub allow_direct_fallback: bool, /// Disable seq.mem JSON event emission. #[arg(long)] pub no_mem_events: bool, /// Override seq.mem JSONEachRow path. #[arg(long)] pub mem_events_path: Option<String>, } #[derive(Subcommand, Debug, Clone)] pub enum SkillsFetchAction { /// Generate skills for one or more dependencies. Dep { /// Dependency names. deps: Vec<String>, /// Force ecosystem for all deps. #[arg(long)] ecosystem: Option<String>, /// Bypass cache and scrape fresh. #[arg(long)] force: bool, }, /// Auto-discover dependencies from manifests and generate skills. Auto { /// Max dependencies per ecosystem. #[arg(long)] top: Option<usize>, /// Comma-separated ecosystem list (npm,pypi,cargo,swift). #[arg(long)] ecosystems: Option<String>, /// Bypass cache and scrape fresh. #[arg(long)] force: bool, }, /// Generate skills from one or more URLs. Url { /// URLs to scrape. urls: Vec<String>, /// Skill name override. #[arg(long)] name: Option<String>, /// Bypass cache and scrape fresh. #[arg(long)] force: bool, }, } #[derive(Args, Debug, Clone)] pub struct UrlCommand { #[command(subcommand)] pub action: UrlAction, } #[derive(Subcommand, Debug, Clone)] pub enum UrlAction { /// Inspect a URL and return a compact normalized summary. Inspect(UrlInspectOpts), /// Crawl a site and return a compact multi-page summary. Crawl(UrlCrawlOpts), } #[derive(Args, Debug, Clone)] pub struct UrlInspectOpts { /// URL to inspect. pub url: String, /// Print machine-readable JSON. #[arg(long)] pub json: bool, /// Include the full markdown/content body when available. #[arg(long)] pub full: bool, /// Provider to use. `auto` tries Cloudflare first, then scraper, then direct fetch. #[arg(long, value_enum, default_value = "auto")] pub provider: UrlInspectProvider, /// Request timeout in seconds. #[arg(long, default_value_t = 20.0)] pub timeout_s: f64, } #[derive(Args, Debug, Clone)] pub struct UrlCrawlOpts { /// Starting URL to crawl. pub url: String, /// Print machine-readable JSON. #[arg(long)] pub json: bool, /// Include full markdown for returned records. #[arg(long)] pub full: bool, /// Maximum number of pages to crawl. #[arg(long, default_value_t = 10)] pub limit: usize, /// Maximum crawl depth from the starting URL. #[arg(long, default_value_t = 2)] pub depth: usize, /// Maximum number of completed records to return in the final summary. #[arg(long, default_value_t = 5)] pub records: usize, /// Crawl source: all discovered URLs, only sitemaps, or only links. #[arg(long, value_enum, default_value = "all")] pub source: UrlCrawlSource, /// Render pages in a browser before extraction. Disabled by default for faster static crawls. #[arg(long, default_value_t = false)] pub render: bool, /// Include external links during crawl. #[arg(long)] pub include_external_links: bool, /// Include subdomains during crawl. #[arg(long)] pub include_subdomains: bool, /// Only include URLs matching these wildcard patterns. #[arg(long = "include-pattern")] pub include_patterns: Vec<String>, /// Exclude URLs matching these wildcard patterns. #[arg(long = "exclude-pattern")] pub exclude_patterns: Vec<String>, /// Max crawl cache age in seconds. #[arg(long)] pub max_age_s: Option<u64>, /// Max time to wait for crawl completion in seconds. #[arg(long, default_value_t = 60.0)] pub wait_timeout_s: f64, /// Poll interval while waiting for completion, in seconds. #[arg(long, default_value_t = 2.0)] pub poll_interval_s: f64, } #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum UrlInspectProvider { Auto, Cloudflare, Scraper, Direct, } #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum UrlCrawlSource { All, Sitemaps, Links, } #[derive(Args, Debug, Clone)] pub struct ToolsCommand { #[command(subcommand)] pub action: Option<ToolsAction>, } #[derive(Subcommand, Debug, Clone)] pub enum ToolsAction { /// List all tools for this project. #[command(alias = "ls")] List, /// Run a tool. Run { /// Tool name (without .ts extension). name: String, /// Arguments to pass to the tool. #[arg(trailing_var_arg = true)] args: Vec<String>, }, /// Create a new tool. New { /// Tool name (kebab-case recommended). name: String, /// Short description of what the tool does. #[arg(short, long)] description: Option<String>, /// Use AI (localcode) to generate the tool implementation. #[arg(long)] ai: bool, }, /// Edit a tool in your editor. Edit { /// Tool name. name: String, }, /// Remove a tool. Remove { /// Tool name. name: String, }, } #[derive(Args, Debug, Clone)] #[command(args_conflicts_with_subcommands = true)] pub struct AgentsCommand { #[command(subcommand)] pub action: Option<AgentsAction>, /// Run a global agent directly (e.g., `f agents explore`). #[arg(trailing_var_arg = true)] pub agent: Vec<String>, } #[derive(Subcommand, Debug, Clone)] pub enum AgentsAction { /// List available agents. #[command(alias = "ls")] List, /// Run an agent with a prompt. Run { /// Agent name (flow, codify, explore, general). agent: String, /// Prompt for the agent. #[arg(trailing_var_arg = true)] prompt: Vec<String>, }, /// Run a global agent (prompt optional). #[command(alias = "g")] Global { /// Global agent name. agent: String, /// Optional custom prompt (uses default if not provided). #[arg(trailing_var_arg = true)] prompt: Option<Vec<String>>, }, /// Copy agent instructions to clipboard (fuzzy select). #[command(alias = "cp")] Copy { /// Optional agent name (fuzzy select if not provided). agent: Option<String>, }, /// Switch agents.md profile (fuzzy select if not provided). Rules { /// Optional profile name (e.g., light). profile: Option<String>, /// Optional repo path (defaults to cwd). repo: Option<String>, }, } /// Hive agent management. #[derive(Args, Debug, Clone)] pub struct HiveCommand { #[command(subcommand)] pub action: Option<HiveAction>, /// Run an agent directly (e.g., `f hive fish "wrap ls"`). #[arg(trailing_var_arg = true)] pub agent: Vec<String>, } #[derive(Subcommand, Debug, Clone)] pub enum HiveAction { /// List available hive agents. #[command(alias = "ls")] List, /// Run a hive agent with a prompt. Run { /// Agent name. agent: String, /// Prompt for the agent. #[arg(trailing_var_arg = true)] prompt: Vec<String>, }, /// Create a new agent spec. New { /// Agent name. name: String, /// Create as global agent (default: project-local). #[arg(short, long)] global: bool, }, /// Edit an agent spec file. Edit { /// Agent name (fuzzy select if not provided). agent: Option<String>, }, /// Show an agent's spec. Show { /// Agent name. agent: String, }, } #[derive(Args, Debug, Clone, Default)] pub struct PublishOpts { /// GitHub repository URL (e.g., https://github.com/org/repo or git@github.com:org/repo.git). #[arg(value_name = "URL")] pub url: Option<String>, /// Repository name (defaults to current folder name). #[arg(short, long)] pub name: Option<String>, /// Repository owner/org (GitHub) or owner (gitedit.dev). #[arg(long)] pub owner: Option<String>, /// Update existing origin remote to match the target repo (GitHub). #[arg(long)] pub set_origin: bool, /// Make the repository public. #[arg(long)] pub public: bool, /// Make the repository private. #[arg(long)] pub private: bool, /// Description for the repository. #[arg(short, long)] pub description: Option<String>, /// Skip confirmation prompts. #[arg(short, long)] pub yes: bool, } #[derive(Args, Debug, Clone)] pub struct PublishCommand { #[command(subcommand)] pub action: Option<PublishAction>, } #[derive(Args, Debug, Clone)] pub struct CloneOpts { /// Repository URL or owner/repo. pub url: String, /// Optional destination directory (same as git clone <url> <dir>). pub directory: Option<String>, } #[derive(Subcommand, Debug, Clone)] pub enum PublishAction { /// Publish to gitedit.dev. Gitedit(PublishOpts), /// Publish to GitHub. Github(PublishOpts), } #[derive(Args, Debug, Clone)] pub struct ReposCommand { #[command(subcommand)] pub action: Option<ReposAction>, } #[derive(Args, Debug, Clone)] pub struct CodeCommand { #[command(subcommand)] pub action: Option<CodeAction>, /// Root directory to scan (default: ~/code). #[arg(long, default_value = "~/code")] pub root: String, } #[derive(Subcommand, Debug, Clone)] pub enum CodeAction { /// List git repos under ~/code. List, /// Create a new project from a template in ~/new/<name>. New(CodeNewOpts), /// Move a folder into ~/code/<relative-path> and migrate AI sessions. Migrate(CodeMigrateOpts), /// Move AI sessions when a project path changes. MoveSessions(CodeMoveSessionsOpts), } #[derive(Args, Debug, Clone)] pub struct CodeNewOpts { /// Template name under ~/new (e.g., "docs"). pub template: String, /// Destination folder name or relative path under the code root. pub name: String, /// Add the new path to .gitignore in the containing repo. #[arg(long)] pub ignored: bool, /// Show what would change without writing. #[arg(long)] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct CodeMigrateOpts { /// Source folder to migrate. pub from: String, /// Relative path under the code root (e.g., "flow/myflow"). pub relative: String, /// Copy instead of move (keeps original). #[arg(long, short)] pub copy: bool, /// Show what would change without writing. #[arg(long)] pub dry_run: bool, /// Skip migrating Claude sessions. #[arg(long)] pub skip_claude: bool, /// Skip migrating Codex sessions. #[arg(long)] pub skip_codex: bool, } #[derive(Args, Debug, Clone)] pub struct CodeMoveSessionsOpts { /// Old project path. #[arg(long)] pub from: String, /// New project path. #[arg(long)] pub to: String, /// Show what would change without writing. #[arg(long)] pub dry_run: bool, /// Skip migrating Claude sessions. #[arg(long)] pub skip_claude: bool, /// Skip migrating Codex sessions. #[arg(long)] pub skip_codex: bool, } #[derive(Args, Debug, Clone)] pub struct MigrateCommand { #[command(subcommand)] pub action: Option<MigrateAction>, /// Source path (defaults to current directory if only one path given). pub source: Option<String>, /// Target path (if source is given, this is the destination). pub target: Option<String>, /// Copy instead of move (keeps original). #[arg(long, short)] pub copy: bool, /// Show what would change without writing. #[arg(long)] pub dry_run: bool, /// Skip migrating Claude sessions. #[arg(long)] pub skip_claude: bool, /// Skip migrating Codex sessions. #[arg(long)] pub skip_codex: bool, } #[derive(Subcommand, Debug, Clone)] pub enum MigrateAction { /// Move or copy current folder to ~/code/<relative-path>. Code(MigrateCodeOpts), } #[derive(Args, Debug, Clone)] pub struct MigrateCodeOpts { /// Relative path under ~/code (e.g., "flow/myflow"). pub relative: String, /// Copy instead of move (keeps original). #[arg(long, short)] pub copy: bool, /// Show what would change without writing. #[arg(long)] pub dry_run: bool, /// Skip migrating Claude sessions. #[arg(long)] pub skip_claude: bool, /// Skip migrating Codex sessions. #[arg(long)] pub skip_codex: bool, } #[derive(Subcommand, Debug, Clone)] pub enum ReposAction { /// Clone a repository into ~/repos/<owner>/<repo>. Clone(ReposCloneOpts), /// Create a GitHub repository from the current folder and push it. Create(PublishOpts), /// Build or inspect a compact repo capsule for path-based Codex context. Capsule(RepoCapsuleOpts), /// Manage repo aliases used by Codex repo-reference resolution. Alias(RepoAliasCommand), } #[derive(Args, Debug, Clone)] pub struct ReposCloneOpts { /// Repository URL or owner/repo. pub url: String, /// Root directory for clones (default: ~/repos). #[arg(long, default_value = "~/repos")] pub root: String, /// Perform a full clone (skip shallow clone + background history fetch). #[arg(long)] pub full: bool, /// Skip automatic upstream setup for forks. #[arg(long)] pub no_upstream: bool, /// Upstream URL override (defaults to fork parent via gh). #[arg(short = 'u', long)] pub upstream_url: Option<String>, } #[derive(Args, Debug, Clone)] pub struct RepoCapsuleOpts { /// Repo or project path to inspect (defaults to the current directory). #[arg(long)] pub path: Option<String>, /// Force a fresh capsule rebuild before printing. #[arg(long)] pub refresh: bool, /// Emit machine-readable JSON. #[arg(long)] pub json: bool, } #[derive(Args, Debug, Clone)] pub struct RepoAliasCommand { #[command(subcommand)] pub action: Option<RepoAliasAction>, } #[derive(Subcommand, Debug, Clone)] pub enum RepoAliasAction { /// List registered repo aliases. #[command(alias = "ls")] List { /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Register or update an alias for a repo path. Set { /// Alias name to register. alias: String, /// Repo or project path for this alias. path: String, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, /// Remove a registered alias. Remove { /// Alias name to remove. alias: String, }, /// Import aliases from Shelf config. #[command(name = "import-shelf")] ImportShelf { /// Override the Shelf config path (default: ~/.agents/shelf/config.json). #[arg(long)] config: Option<String>, /// Emit machine-readable JSON. #[arg(long)] json: bool, }, } #[derive(Args, Debug, Clone)] pub struct SyncCommand { /// Use rebase instead of merge when pulling. #[arg(long, short)] pub rebase: bool, /// Push to configured git remote after sync (default: false). #[arg(long)] pub push: bool, /// Skip pushing to configured git remote (legacy; default is already no push). #[arg(long, overrides_with = "push")] pub no_push: bool, /// Auto-stash uncommitted changes (default: true). #[arg(long, short, default_value = "true")] pub stash: bool, /// Stash local JJ commits to a bookmark before syncing (JJ-only). #[arg(long, default_value = "false")] pub stash_commits: bool, /// Allow sync/rebase even when commit queue is non-empty. #[arg(long)] pub allow_queue: bool, /// Create origin repo on GitHub if it doesn't exist. #[arg(long)] pub create_repo: bool, /// Auto-fix conflicts and errors using Claude (default: true). #[arg(long, short, default_value = "true", action = clap::ArgAction::Set)] pub fix: bool, /// Disable auto-fix (same as --fix=false). #[arg(long, overrides_with = "fix")] pub no_fix: bool, /// Maximum fix attempts before giving up. #[arg(long, default_value = "3")] pub max_fix_attempts: u32, /// Allow push even if P1/P2 review todos are open. #[arg(long)] pub allow_review_issues: bool, /// Reduce sync output noise (show remote update counts without commit line listings). #[arg(long)] pub compact: bool, } #[derive(Args, Debug, Clone)] pub struct SwitchCommand { /// Branch name, PR number (for example: 123 or #123), or PR URL. pub branch: String, /// Preferred remote to track from (default: upstream, then origin). #[arg(long)] pub remote: Option<String>, /// Auto-preserve a safety snapshot branch/bookmark before switching (default: true). #[arg(long, default_value = "true", action = clap::ArgAction::Set)] pub preserve: bool, /// Disable safety snapshot preservation (same as --preserve=false). #[arg(long, overrides_with = "preserve")] pub no_preserve: bool, /// Auto-stash uncommitted changes before switching (default: true). #[arg(long, default_value = "true", action = clap::ArgAction::Set)] pub stash: bool, /// Disable auto-stash (same as --stash=false). #[arg(long, overrides_with = "stash")] pub no_stash: bool, /// Run sync after switching (uses --no-push). #[arg(long)] pub sync: bool, } #[derive(Args, Debug, Clone)] pub struct CheckoutCommand { /// PR URL, PR number, or branch accepted by `gh pr checkout`. pub target: String, /// Preferred remote to use when checking out a PR branch. #[arg(long)] pub remote: Option<String>, /// Auto-stash uncommitted changes before checkout (default: true). #[arg(long, default_value = "true", action = clap::ArgAction::Set)] pub stash: bool, /// Disable auto-stash (same as --stash=false). #[arg(long, overrides_with = "stash")] pub no_stash: bool, } #[derive(Args, Debug, Clone)] pub struct UpstreamCommand { #[command(subcommand)] pub action: Option<UpstreamAction>, } #[derive(Subcommand, Debug, Clone)] pub enum UpstreamAction { /// Show current upstream configuration. Status, /// Set up upstream remote and local tracking branch. Setup { /// URL of the upstream repository. #[arg(short, long)] upstream_url: Option<String>, /// Branch name on upstream (default: main). #[arg(short, long)] upstream_branch: Option<String>, }, /// Pull changes from upstream into local 'upstream' branch. Pull { /// Also merge into this branch after pulling. #[arg(short, long)] branch: Option<String>, }, /// Checkout local 'upstream' branch synced to upstream. Check, /// Full sync: pull upstream, merge to dev/main, push to origin. Sync { /// Skip pushing to origin. #[arg(long)] no_push: bool, /// Create origin repo on GitHub if it doesn't exist. #[arg(long)] create_repo: bool, }, /// Open upstream repository URL in browser. Open, } #[derive(Args, Debug, Clone)] pub struct NotifyCommand { /// Title of the proposal (shown in widget header). #[arg(short, long)] pub title: Option<String>, /// The action/command to propose (e.g., "f deploy"). pub action: String, /// Optional context or description. #[arg(short, long)] pub context: Option<String>, /// Expiration time in seconds (default: 300 = 5 minutes). #[arg(short, long, default_value = "300")] pub expires: u64, } #[derive(Args, Debug, Clone)] pub struct CommitsCommand { #[command(subcommand)] pub action: Option<CommitsAction>, #[command(flatten)] pub opts: CommitsOpts, } #[derive(Subcommand, Debug, Clone)] pub enum CommitsAction { /// List notable commits. Top, /// Mark a commit as notable. Mark { /// Commit hash (short or full). hash: String, }, /// Remove a commit from notable list. Unmark { /// Commit hash (short or full). hash: String, }, } #[derive(Args, Debug, Clone, Default)] pub struct CommitsOpts { /// Number of commits to show (default: 100). #[arg(long, short = 'n', default_value_t = 100)] pub limit: usize, /// Show commits across all branches. #[arg(long)] pub all: bool, } #[derive(Args, Debug, Clone)] pub struct SeqRpcCommand { /// Path to seqd Unix socket (default: $SEQ_SOCKET_PATH, then /tmp/seqd.sock). #[arg(long)] pub socket: Option<PathBuf>, /// Read/write timeout in milliseconds. #[arg(long, default_value_t = 5000)] pub timeout_ms: u64, /// Pretty-print JSON response. #[arg(long)] pub pretty: bool, #[command(subcommand)] pub action: SeqRpcAction, } #[derive(Subcommand, Debug, Clone)] pub enum SeqRpcAction { /// Health check. Ping(SeqRpcIdOpts), /// Current/previous foreground app snapshot. AppState(SeqRpcIdOpts), /// Daemon perf/rusage snapshot. Perf(SeqRpcIdOpts), /// Open application by name. OpenApp(SeqRpcOpenAppOpts), /// Toggle application by name. OpenAppToggle(SeqRpcOpenAppOpts), /// Save screenshot to path. Screenshot(SeqRpcScreenshotOpts), /// Raw operation and optional JSON args. Rpc(SeqRpcRawOpts), } #[derive(Args, Debug, Clone, Default)] pub struct SeqRpcIdOpts { /// Caller-owned id for request correlation. #[arg(long)] pub request_id: Option<String>, /// Caller run id. #[arg(long)] pub run_id: Option<String>, /// Caller tool call id. #[arg(long)] pub tool_call_id: Option<String>, } #[derive(Args, Debug, Clone)] pub struct SeqRpcOpenAppOpts { /// Application name (e.g., "Safari", "Google Chrome"). pub name: String, #[command(flatten)] pub ids: SeqRpcIdOpts, } #[derive(Args, Debug, Clone)] pub struct SeqRpcScreenshotOpts { /// Output file path. pub path: String, #[command(flatten)] pub ids: SeqRpcIdOpts, } #[derive(Args, Debug, Clone)] pub struct SeqRpcRawOpts { /// Operation name (e.g., ping, open_app, click). pub op: String, /// Optional JSON args payload. #[arg(long)] pub args_json: Option<String>, #[command(flatten)] pub ids: SeqRpcIdOpts, } #[derive(Args, Debug, Clone)] pub struct ExplainCommitsCommand { /// Number of commits to explain (default: 1). pub count: Option<usize>, /// Re-explain even if already processed. #[arg(long)] pub force: bool, /// Output directory (relative to repo root unless absolute). #[arg(long)] pub out_dir: Option<PathBuf>, } #[derive(Args, Debug, Clone)] pub struct DeployCommand { #[command(subcommand)] pub action: Option<DeployAction>, } #[derive(Args, Debug, Clone)] pub struct ReleaseOpts { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, /// Additional arguments passed to the release task command. #[arg(value_name = "ARGS", trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct ReleaseCommand { /// Path to the project flow config (flow.toml). #[arg(long, default_value = "flow.toml")] pub config: PathBuf, #[command(subcommand)] pub action: Option<ReleaseAction>, } #[derive(Subcommand, Debug, Clone)] pub enum ReleaseAction { /// Run the configured release task. Task(ReleaseTaskOpts), /// Publish a release to a Flow registry. Registry(RegistryReleaseOpts), /// Manage GitHub releases. #[command(alias = "gh")] Github(GhReleaseCommand), /// Manage macOS code signing and GitHub Actions secrets for releases. Signing(ReleaseSigningCommand), } #[derive(Args, Debug, Clone)] pub struct ReleaseSigningCommand { #[command(subcommand)] pub action: ReleaseSigningAction, } #[derive(Subcommand, Debug, Clone)] pub enum ReleaseSigningAction { /// Show current signing setup status (Keychain + Flow env store). Status, /// Store signing secrets into Flow personal env store. Store(ReleaseSigningStoreOpts), /// Sync signing secrets from Flow env store into GitHub Actions secrets. Sync(ReleaseSigningSyncOpts), } #[derive(Args, Debug, Clone)] pub struct ReleaseSigningStoreOpts { /// Path to exported .p12 file (Developer ID Application certificate + key). #[arg(long)] pub p12: Option<PathBuf>, /// Password for the .p12 (must match what the release workflow imports with). #[arg(long)] pub p12_password: Option<String>, /// Signing identity passed to `codesign` (e.g. "Developer ID Application: ... (TEAMID)"). #[arg(long)] pub identity: Option<String>, /// Dry run: show what would be stored without writing to env store. #[arg(long)] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct ReleaseSigningSyncOpts { /// GitHub repo in "owner/repo" form (defaults to repo inferred from current directory). #[arg(long)] pub repo: Option<String>, /// Dry run: show what would be synced without calling `gh`. #[arg(long)] pub dry_run: bool, } #[derive(Args, Debug, Clone, Default)] pub struct ReleaseTaskOpts { /// Additional arguments passed to the release task command. #[arg(value_name = "ARGS", trailing_var_arg = true)] pub args: Vec<String>, } #[derive(Args, Debug, Clone, Default)] pub struct RegistryReleaseOpts { /// Version to publish (auto-detected if omitted). #[arg(long, short)] pub version: Option<String>, /// Registry base URL (overrides flow.toml). #[arg(long)] pub registry: Option<String>, /// Override package name for the registry. #[arg(long)] pub package: Option<String>, /// Override the binary name(s) to upload. #[arg(long, value_name = "BIN")] pub bin: Vec<String>, /// Skip building binaries before publishing. #[arg(long)] pub no_build: bool, /// Mark this version as latest in the registry. #[arg(long, conflicts_with = "no_latest")] pub latest: bool, /// Skip updating the latest pointer. #[arg(long, conflicts_with = "latest")] pub no_latest: bool, /// Dry run: show what would be published without publishing. #[arg(long, short = 'n')] pub dry_run: bool, } #[derive(Args, Debug, Clone)] pub struct InstallOpts { /// Package name to install (leave blank to search). pub name: Option<String>, /// Registry base URL (defaults to FLOW_REGISTRY_URL). #[arg(long)] pub registry: Option<String>, /// Install backend (auto tries registry, then parm, then flox). #[arg(long, value_enum, default_value = "auto")] pub backend: InstallBackend, /// Version to install (defaults to latest). #[arg(long, short)] pub version: Option<String>, /// Binary name to install (defaults to the package name or manifest default). #[arg(long)] pub bin: Option<String>, /// Install directory (defaults to ~/bin). #[arg(long)] pub bin_dir: Option<PathBuf>, /// Skip checksum verification. #[arg(long)] pub no_verify: bool, /// Overwrite existing binary if present. #[arg(long)] pub force: bool, } #[derive(Args, Debug, Clone)] pub struct InstallCommand { #[command(subcommand)] pub action: Option<InstallAction>, #[command(flatten)] pub opts: InstallOpts, } #[derive(Subcommand, Debug, Clone)] pub enum InstallAction { /// Index flox packages into Typesense. Index(InstallIndexOpts), } #[derive(Args, Debug, Clone)] pub struct InstallIndexOpts { /// Search term to index (defaults to prompt). pub query: Option<String>, /// File with newline-separated search terms. #[arg(long)] pub queries: Option<PathBuf>, /// Typesense base URL (overrides FLOW_TYPESENSE_URL). #[arg(long)] pub url: Option<String>, /// Typesense API key (overrides FLOW_TYPESENSE_API_KEY). #[arg(long)] pub api_key: Option<String>, /// Typesense collection name (overrides FLOW_TYPESENSE_COLLECTION). #[arg(long, default_value = "flox-packages")] pub collection: String, /// Index server URL (defaults to local base server). #[arg(long, default_value = "http://127.0.0.1:9417")] pub server: String, /// Skip index server and write directly to Typesense. #[arg(long)] pub direct: bool, /// Max results per search term. #[arg(long, default_value_t = 200)] pub per_page: usize, /// Dry run (do not write to Typesense). #[arg(long, short = 'n')] pub dry_run: bool, } #[derive(ValueEnum, Debug, Clone, Copy)] pub enum InstallBackend { Auto, Registry, Flox, /// Install GitHub release binaries via the external `parm` tool. Parm, } #[derive(Args, Debug, Clone)] pub struct FishInstallOpts { /// Path to fish-shell source repo (auto-detected if not set). #[arg(long)] pub source: Option<PathBuf>, /// Install directory for the fish binary (defaults to ~/.local/bin). #[arg(long)] pub bin_dir: Option<PathBuf>, /// Force reinstall even if already installed. #[arg(long)] pub force: bool, /// Skip confirmation prompt. #[arg(long, short = 'y')] pub yes: bool, } #[derive(Args, Debug, Clone)] pub struct RegistryCommand { #[command(subcommand)] pub action: Option<RegistryAction>, } #[derive(Subcommand, Debug, Clone)] pub enum RegistryAction { /// Create a registry token and configure worker + env. Init(RegistryInitOpts), } #[derive(Args, Debug, Clone)] pub struct RegistryInitOpts { /// Path to the worker project (defaults to packages/worker). #[arg(long, short)] pub worker: Option<PathBuf>, /// Registry base URL (overrides flow.toml or FLOW_REGISTRY_URL). #[arg(long)] pub registry: Option<String>, /// Env var name for the registry token. #[arg(long)] pub token_env: Option<String>, /// Provide an explicit token instead of generating one. #[arg(long)] pub token: Option<String>, /// Skip updating the worker secret. #[arg(long)] pub no_worker: bool, /// Print the generated token to stdout. #[arg(long)] pub show_token: bool, } #[derive(Subcommand, Debug, Clone)] pub enum DeployAction { /// Deploy to Linux host via SSH. #[command(alias = "h")] Host { /// Build remotely instead of syncing local build artifacts. #[arg(long)] remote_build: bool, /// Run setup script even if already deployed. #[arg(long)] setup: bool, }, /// Deploy to Cloudflare Workers. #[command(alias = "cf")] Cloudflare { /// Also set secrets from env_file. #[arg(long)] secrets: bool, /// Run in dev mode instead of deploying. #[arg(long)] dev: bool, }, /// Deploy the web site (Cloudflare). Web, /// Interactive deploy setup (Cloudflare Workers for now). Setup, /// Deploy to Railway. Railway, /// Configure deployment defaults (Linux host). Config, /// Run the project's release task. Release(ReleaseOpts), /// Show deployment status. Status, /// View deployment logs. Logs { /// Follow logs in real-time. #[arg(long, short)] follow: bool, /// Show logs since the last successful deploy (default). #[arg(long, default_value_t = true)] since_deploy: bool, /// Show full log history (ignores --since-deploy). #[arg(long)] all: bool, /// Number of lines to show. #[arg(long, short = 'n', default_value_t = 100)] lines: usize, }, /// Restart the deployed service. Restart, /// Stop the deployed service. Stop, /// SSH into the host (for host deployments). Shell, /// Configure host for deployment. #[command(alias = "set")] SetHost { /// SSH connection string (user@host:port or user@host). connection: String, }, /// Show current host configuration. ShowHost, /// Check if deployment is healthy (HTTP health check). Health { /// Custom URL to check (defaults to domain from config). #[arg(long)] url: Option<String>, /// Expected HTTP status code. #[arg(long, default_value_t = 200)] status: u16, }, } #[derive(Args, Debug, Clone)] pub struct ParallelCommand { /// Maximum number of concurrent jobs (default: number of CPU cores). #[arg(long, short = 'j')] pub jobs: Option<usize>, /// Stop all tasks on first failure. #[arg(long, short = 'f')] pub fail_fast: bool, /// Tasks to run as "label:command" pairs, or just commands (auto-labeled). #[arg(value_name = "TASK", trailing_var_arg = true, num_args = 1..)] pub tasks: Vec<String>, } #[derive(Args, Debug, Clone)] pub struct DocsCommand { #[command(subcommand)] pub action: Option<DocsAction>, } #[derive(Args, Debug, Clone)] pub struct UpgradeOpts { /// Upgrade to a specific version (e.g., "0.2.0" or "v0.2.0"). #[arg(value_name = "VERSION")] pub version: Option<String>, /// Upgrade to the latest canary build (GitHub release tag: "canary"). /// /// This is similar to `bun upgrade --canary`: canary releases are updated frequently and may /// contain untested changes. #[arg(long, conflicts_with = "stable", conflicts_with = "version")] pub canary: bool, /// Upgrade to the latest stable release (GitHub "latest" release). /// /// This is useful to switch back after installing canary. #[arg(long, conflicts_with = "canary", conflicts_with = "version")] pub stable: bool, /// Print what would happen without making changes. #[arg(long, short = 'n')] pub dry_run: bool, /// Force upgrade even if already on the latest version. #[arg(long, short)] pub force: bool, /// Download to a specific path instead of replacing the current executable. #[arg(long, short)] pub output: Option<String>, } #[derive(Args, Debug, Clone)] pub struct GhReleaseCommand { #[command(subcommand)] pub action: Option<GhReleaseAction>, } #[derive(Subcommand, Debug, Clone)] pub enum GhReleaseAction { /// Create a new GitHub release. Create(GhReleaseCreateOpts), /// List recent releases. #[command(alias = "ls")] List { /// Number of releases to show. #[arg(short, long, default_value = "10")] limit: usize, }, /// Delete a release. Delete { /// Release tag to delete. tag: String, /// Skip confirmation. #[arg(short, long)] yes: bool, }, /// Download release assets. Download { /// Release tag (defaults to latest). #[arg(short, long)] tag: Option<String>, /// Output directory. #[arg(short, long, default_value = ".")] output: String, }, } #[derive(Args, Debug, Clone)] pub struct GhReleaseCreateOpts { /// Version tag (e.g., "v0.1.0"). Auto-detected from Cargo.toml if not provided. #[arg(value_name = "TAG")] pub tag: Option<String>, /// Release title (defaults to tag name). #[arg(short, long)] pub title: Option<String>, /// Release notes (reads from stdin or file if not provided). #[arg(short, long)] pub notes: Option<String>, /// Read release notes from a file. #[arg(long)] pub notes_file: Option<String>, /// Generate release notes automatically from commits. #[arg(long)] pub generate_notes: bool, /// Create as draft release. #[arg(long)] pub draft: bool, /// Mark as prerelease. #[arg(long)] pub prerelease: bool, /// Asset files to upload (can be specified multiple times). #[arg(short, long, value_name = "FILE")] pub asset: Vec<String>, /// Target commit/branch for the release tag. #[arg(long)] pub target: Option<String>, /// Skip confirmation prompts. #[arg(short, long)] pub yes: bool, } #[derive(Subcommand, Debug, Clone)] pub enum DocsAction { /// Create a docs/ folder with starter markdown files. New(DocsNewOpts), /// Run the docs hub that aggregates docs from ~/code and ~/org. Hub(DocsHubOpts), /// Deploy the docs hub to Cloudflare Pages. Deploy(DocsDeployOpts), /// Sync documentation with recent commits. Sync { /// Number of commits to analyze (default: 10). #[arg(long, short = 'n', default_value_t = 10)] commits: usize, /// Dry run: show what would be updated without changing files. #[arg(long)] dry: bool, }, /// List documentation files. #[command(alias = "ls")] List, /// Show documentation status (what needs updating). Status, /// Open a doc file in editor. Edit { /// Doc file name (without .md). name: String, }, } #[derive(Args, Debug, Clone)] pub struct DocsNewOpts { /// Path to create docs in (defaults to current directory). #[arg(long)] pub path: Option<PathBuf>, /// Overwrite if docs/ already exists. #[arg(long)] pub force: bool, } #[derive(Args, Debug, Clone)] pub struct DocsDeployOpts { /// Cloudflare Pages project name (defaults to flow.toml name). #[arg(long)] pub project: Option<String>, /// Custom domain to attach (optional). #[arg(long)] pub domain: Option<String>, /// Skip confirmation prompts. #[arg(short, long)] pub yes: bool, } #[derive(Args, Debug, Clone)] pub struct DocsHubOpts { /// Host to bind the docs hub to. #[arg(long, default_value = "127.0.0.1")] pub host: String, /// Port for the docs hub. #[arg(long, default_value_t = 4410)] pub port: u16, /// Docs hub root (defaults to ~/.config/flow/docs-hub). #[arg(long, default_value = "~/.config/flow/docs-hub")] pub hub_root: String, /// Template root (defaults to ~/new/docs). #[arg(long, default_value = "~/new/docs")] pub template_root: String, /// Code root to scan for docs (defaults to ~/code). #[arg(long, default_value = "~/code")] pub code_root: String, /// Org root to scan for docs (defaults to ~/org). #[arg(long, default_value = "~/org")] pub org_root: String, /// Skip scanning for .ai/docs. #[arg(long)] pub no_ai: bool, /// Skip opening the browser. #[arg(long)] pub no_open: bool, /// Sync content and exit without running the dev server. #[arg(long)] pub sync_only: bool, } #[cfg(test)] mod tests { use super::*; use clap::Parser; #[test] fn parses_codex_resume_with_path_override() { let cli = Cli::parse_from([ "f", "ai", "codex", "resume", "--path", "~/work/example-project", "session-123", ]); match cli.command { Some(Commands::Ai(AiCommand { action: Some(AiAction::Codex { action: Some(ProviderAiAction::Resume { session, path }), }), })) => { assert_eq!(session.as_deref(), Some("session-123")); assert_eq!(path.as_deref(), Some("~/work/example-project")); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_codex_continue_with_path_override() { let cli = Cli::parse_from(["f", "ai", "codex", "continue", "--path", "/tmp/rev"]); match cli.command { Some(Commands::Ai(AiCommand { action: Some(AiAction::Codex { action: Some(ProviderAiAction::Continue { session, path }), }), })) => { assert_eq!(session, None); assert_eq!(path.as_deref(), Some("/tmp/rev")); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_codex_find_with_path_and_query() { let cli = Cli::parse_from([ "f", "ai", "codex", "find", "--path", "~/repos/acme/app", "make", "plan", "designer", ]); match cli.command { Some(Commands::Ai(AiCommand { action: Some(AiAction::Codex { action: Some(ProviderAiAction::Find { path, exact_cwd, query, }), }), })) => { assert_eq!(path.as_deref(), Some("~/repos/acme/app")); assert!(!exact_cwd); assert_eq!(query, vec!["make", "plan", "designer"]); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_codex_find_and_copy_with_query() { let cli = Cli::parse_from([ "f", "ai", "codex", "findAndCopy", "make", "plan", "designer", ]); match cli.command { Some(Commands::Ai(AiCommand { action: Some(AiAction::Codex { action: Some(ProviderAiAction::FindAndCopy { path, exact_cwd, query, }), }), })) => { assert_eq!(path, None); assert!(!exact_cwd); assert_eq!(query, vec!["make", "plan", "designer"]); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_codex_open_with_path_and_query() { let cli = Cli::parse_from([ "f", "codex", "open", "--path", "~/repos/acme/app", "continue", "the", "deploy", "work", ]); match cli.command { Some(Commands::Codex { action: Some(ProviderAiAction::Open { path, exact_cwd, query, }), }) => { assert_eq!(path.as_deref(), Some("~/repos/acme/app")); assert!(!exact_cwd); assert_eq!(query, vec!["continue", "the", "deploy", "work"]); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_codex_resolve_json() { let cli = Cli::parse_from([ "f", "ai", "codex", "resolve", "--json", "https://linear.app/fl2024008/project/llm-proxy-v1-6cd0a041bd76/overview", ]); match cli.command { Some(Commands::Ai(AiCommand { action: Some(AiAction::Codex { action: Some(ProviderAiAction::Resolve { path, exact_cwd, json, query, }), }), })) => { assert_eq!(path, None); assert!(!exact_cwd); assert!(json); assert_eq!( query, vec!["https://linear.app/fl2024008/project/llm-proxy-v1-6cd0a041bd76/overview"] ); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_codex_doctor_assertions() { let cli = Cli::parse_from([ "f", "codex", "doctor", "--path", "~/docs", "--assert-runtime", "--assert-learning", "--json", ]); match cli.command { Some(Commands::Codex { action: Some(ProviderAiAction::Doctor { path, assert_runtime, assert_schedule, assert_learning, assert_autonomous, json, }), }) => { assert_eq!(path.as_deref(), Some("~/docs")); assert!(assert_runtime); assert!(!assert_schedule); assert!(assert_learning); assert!(!assert_autonomous); assert!(json); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_codex_enable_global_full() { let cli = Cli::parse_from(["f", "codex", "enable-global", "--full", "--dry-run"]); match cli.command { Some(Commands::Codex { action: Some(ProviderAiAction::EnableGlobal { dry_run, install_launchd, start_daemon, sync_skills, full, minutes, limit, max_targets, within_hours, }), }) => { assert!(dry_run); assert!(!install_launchd); assert!(!start_daemon); assert!(!sync_skills); assert!(full); assert_eq!(minutes, 30); assert_eq!(limit, 400); assert_eq!(max_targets, 12); assert_eq!(within_hours, 168); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_repos_capsule_with_refresh_and_json() { let cli = Cli::parse_from([ "f", "repos", "capsule", "--path", "~/repos/Effect-TS/effect-smol", "--refresh", "--json", ]); match cli.command { Some(Commands::Repos(ReposCommand { action: Some(ReposAction::Capsule(RepoCapsuleOpts { path, refresh, json, })), })) => { assert_eq!(path.as_deref(), Some("~/repos/Effect-TS/effect-smol")); assert!(refresh); assert!(json); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_repos_alias_set() { let cli = Cli::parse_from([ "f", "repos", "alias", "set", "effect-smol", "~/repos/Effect-TS/effect-smol", "--json", ]); match cli.command { Some(Commands::Repos(ReposCommand { action: Some(ReposAction::Alias(RepoAliasCommand { action: Some(RepoAliasAction::Set { alias, path, json }), })), })) => { assert_eq!(alias, "effect-smol"); assert_eq!(path, "~/repos/Effect-TS/effect-smol"); assert!(json); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_repos_alias_import_shelf() { let cli = Cli::parse_from([ "f", "repos", "alias", "import-shelf", "--config", "~/.agents/shelf/config.json", "--json", ]); match cli.command { Some(Commands::Repos(ReposCommand { action: Some(ReposAction::Alias(RepoAliasCommand { action: Some(RepoAliasAction::ImportShelf { config, json }), })), })) => { assert_eq!(config.as_deref(), Some("~/.agents/shelf/config.json")); assert!(json); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_domains_get_with_target_flag() { let cli = Cli::parse_from([ "f", "domains", "--engine", "native", "get", "myflow.localhost", "--target", ]); match cli.command { Some(Commands::Domains(DomainsCommand { engine: Some(DomainsEngineArg::Native), action: Some(DomainsAction::Get(DomainsGetOpts { host, target })), })) => { assert_eq!(host, "myflow.localhost"); assert!(target); } other => panic!("unexpected parsed command: {other:?}"), } } #[test] fn parses_url_crawl_with_filters() { let cli = Cli::parse_from([ "f", "url", "crawl", "https://developers.cloudflare.com/", "--limit", "6", "--records", "3", "--include-pattern", "https://developers.cloudflare.com/browser-rendering/*", "--exclude-pattern", "*/changelog/*", "--render", ]); match cli.command { Some(Commands::Url(UrlCommand { action: UrlAction::Crawl(UrlCrawlOpts { url, json, full, limit, depth, records, source, render, include_external_links, include_subdomains, include_patterns, exclude_patterns, max_age_s, wait_timeout_s, poll_interval_s, }), })) => { assert_eq!(url, "https://developers.cloudflare.com/"); assert!(!json); assert!(!full); assert_eq!(limit, 6); assert_eq!(depth, 2); assert_eq!(records, 3); assert_eq!(source, UrlCrawlSource::All); assert!(render); assert!(!include_external_links); assert!(!include_subdomains); assert_eq!( include_patterns, vec!["https://developers.cloudflare.com/browser-rendering/*"] ); assert_eq!(exclude_patterns, vec!["*/changelog/*"]); assert_eq!(max_age_s, None); assert_eq!(wait_timeout_s, 60.0); assert_eq!(poll_interval_s, 2.0); } other => panic!("unexpected parsed command: {other:?}"), } } } ================================================ FILE: src/code.rs ================================================ use std::collections::HashSet; use std::collections::hash_map::DefaultHasher; use std::fs; use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use serde_json::Value; use crate::cli::{ CodeAction, CodeCommand, CodeMigrateOpts, CodeMoveSessionsOpts, CodeNewOpts, MigrateAction, MigrateCommand, NewOpts, }; use crate::config; const DEFAULT_CODE_ROOT: &str = "~/code"; const DEFAULT_TEMPLATE_ROOT: &str = "~/new"; const DEFAULT_AGENT_QA_ZVEC_JSONL: &str = "~/repos/alibaba/zvec/data/agent_qa.jsonl"; const FLOW_AGENT_QA_ZVEC_JSONL_ENV: &str = "FLOW_AGENT_QA_ZVEC_JSONL"; struct ZvecMoveSummary { updated_docs: usize, index_found: bool, } struct ZvecCopySummary { copied_docs: usize, index_found: bool, } /// List available templates from ~/new/. fn list_templates() -> Result<Vec<String>> { let template_root = config::expand_path(DEFAULT_TEMPLATE_ROOT); if !template_root.exists() { return Ok(vec![]); } let mut templates = Vec::new(); for entry in fs::read_dir(&template_root)? { let entry = entry?; let path = entry.path(); if path.is_dir() { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if !name.starts_with('.') { templates.push(name.to_string()); } } } } templates.sort(); Ok(templates) } /// Fuzzy select a template from ~/new/. fn fuzzy_select_template() -> Result<Option<String>> { let templates = list_templates()?; if templates.is_empty() { bail!("No templates found in ~/new/"); } let input = templates.join("\n"); let mut fzf = Command::new("fzf") .args(["--height=50%", "--reverse", "--prompt=Template: "]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; fzf.stdin.as_mut().unwrap().write_all(input.as_bytes())?; let output = fzf.wait_with_output()?; if !output.status.success() { return Ok(None); } let selected = String::from_utf8_lossy(&output.stdout).trim().to_string(); if selected.is_empty() { return Ok(None); } Ok(Some(selected)) } /// Create a new project from a template at a specific path. /// Usage: f new [template] [path] pub fn new_from_template(opts: NewOpts) -> Result<()> { let template_root = config::expand_path(DEFAULT_TEMPLATE_ROOT); // Get template name (fuzzy select if not provided) let template_name = match opts.template { Some(t) => t, None => match fuzzy_select_template()? { Some(t) => t, None => return Ok(()), // User cancelled }, }; let template_dir = template_root.join(template_name.trim()); if !template_dir.exists() { bail!("Template not found: {}", template_dir.display()); } if !template_dir.is_dir() { bail!( "Template path is not a directory: {}", template_dir.display() ); } // Resolve target path: // - No path: ./<template_name> // - Starts with ./ or ../: relative to cwd // - Starts with ~ or /: absolute path // - Otherwise: relative to ~/code/ let target = match opts.path { None => std::env::current_dir()?.join(&template_name), Some(p) => { let trimmed = p.trim(); if trimmed.starts_with("./") || trimmed.starts_with("../") || trimmed.starts_with('/') || trimmed.starts_with('~') { let expanded = config::expand_path(trimmed); if expanded.is_absolute() { expanded } else { std::env::current_dir()?.join(&expanded) } } else { // Relative name like "zerg" → ~/code/zerg config::expand_path(DEFAULT_CODE_ROOT).join(trimmed) } } }; if target.exists() { bail!("Destination already exists: {}", target.display()); } if opts.dry_run { println!( "Would copy template {} -> {}", template_dir.display(), target.display() ); return Ok(()); } // Create parent directories if needed if let Some(parent) = target.parent() { if !parent.exists() { fs::create_dir_all(parent).with_context(|| { format!("failed to create parent directory {}", parent.display()) })?; } } copy_dir_all(&template_dir, &target)?; println!("Created {}", target.display()); Ok(()) } pub fn run(cmd: CodeCommand) -> Result<()> { match cmd.action { Some(CodeAction::List) => list_code(&cmd.root), Some(CodeAction::New(opts)) => new_project(opts, &cmd.root), Some(CodeAction::Migrate(opts)) => migrate_project(opts, &cmd.root), Some(CodeAction::MoveSessions(opts)) => move_sessions(opts), None => fuzzy_select_code(&cmd.root), } } pub(crate) fn migrate_sessions_between_paths( from: &Path, to: &Path, dry_run: bool, skip_claude: bool, skip_codex: bool, ) -> Result<()> { let opts = CodeMoveSessionsOpts { from: from.display().to_string(), to: to.display().to_string(), dry_run, skip_claude, skip_codex, }; move_sessions(opts) } /// Migrate current folder to a new location. /// `f migrate code <relative>` → moves to ~/code/<relative> /// `f migrate <target>` → moves to any specified path pub fn run_migrate(cmd: MigrateCommand) -> Result<()> { let from = std::env::current_dir().context("failed to get current directory")?; // Handle `f migrate code <relative>` subcommand if let Some(MigrateAction::Code(opts)) = cmd.action { // Merge flags from parent command and subcommand (subcommand takes precedence if set) let copy = opts.copy || cmd.copy; let dry_run = opts.dry_run || cmd.dry_run; let skip_claude = opts.skip_claude || cmd.skip_claude; let skip_codex = opts.skip_codex || cmd.skip_codex; let migrate_opts = CodeMigrateOpts { from: from.to_string_lossy().to_string(), relative: opts.relative, copy, dry_run, skip_claude, skip_codex, }; return migrate_project(migrate_opts, DEFAULT_CODE_ROOT); } // Handle `f migrate <source> <target>` or `f migrate <target>` let (from, target) = match (cmd.source, cmd.target) { // Both source and target provided: f migrate <source> <target> (Some(src), Some(tgt)) => { let src_path = config::expand_path(&src); let src_path = if src_path.is_absolute() { src_path } else { std::env::current_dir()?.join(&src_path) }; let tgt_path = config::expand_path(&tgt); let tgt_path = if tgt_path.is_absolute() { tgt_path } else { std::env::current_dir()?.join(&tgt_path) }; (src_path, tgt_path) } // Only one path: f migrate <target> (source is cwd) (Some(tgt), None) => { let tgt_path = config::expand_path(&tgt); let tgt_path = if tgt_path.is_absolute() { tgt_path } else { std::env::current_dir()?.join(&tgt_path) }; (from, tgt_path) } // No paths provided (None, _) => { bail!( "Usage: f migrate <target> OR f migrate <source> <target> OR f migrate code <relative>" ); } }; migrate_to_path( &from, &target, cmd.copy, cmd.dry_run, cmd.skip_claude, cmd.skip_codex, ) } /// Migrate a folder to an arbitrary target path (not necessarily ~/code). fn migrate_to_path( from: &Path, target: &Path, copy: bool, dry_run: bool, skip_claude: bool, skip_codex: bool, ) -> Result<()> { let target_display = target.display().to_string(); let action = if copy { "copy" } else { "move" }; let action_past = if copy { "Copied" } else { "Moved" }; if from == target { bail!("Source and destination are the same path."); } if !from.exists() { bail!("Source folder does not exist: {}", from.display()); } if !from.is_dir() { bail!("Source path is not a directory: {}", from.display()); } if target.exists() { bail!("Destination already exists: {}", target.display()); } if target.starts_with(from) { bail!("Destination cannot be inside the source folder."); } // Create parent directories if needed if let Some(parent) = target.parent() { if !parent.exists() { if dry_run { println!("Would create {}", parent.display()); } else { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } } } if dry_run { println!("Would {} {} -> {}", action, from.display(), target_display); } else if copy { copy_dir_all(from, target)?; println!("{} {} -> {}", action_past, from.display(), target_display); } else { move_dir(from, target)?; println!("{} {} -> {}", action_past, from.display(), target_display); } // Only relink symlinks if moving (not copying) if !copy { let relinked = relink_bin_symlinks(from, target, dry_run)?; if relinked > 0 { println!("Updated {} symlink(s) in ~/bin", relinked); } } let session_opts = CodeMoveSessionsOpts { from: from.to_string_lossy().to_string(), to: target.to_string_lossy().to_string(), dry_run, skip_claude, skip_codex, }; if copy { copy_sessions(session_opts) .with_context(|| format!("copied to {}, but session copy failed", target_display))?; } else { move_sessions(session_opts).with_context(|| { format!("moved to {}, but session migration failed", target_display) })?; } Ok(()) } fn list_code(root: &str) -> Result<()> { let root = normalize_root(root)?; if !root.exists() { println!("No code directory found at {}", root.display()); return Ok(()); } let repos = discover_code_repos(&root)?; if repos.is_empty() { println!("No git repositories found in {}", root.display()); return Ok(()); } println!("Available repositories:"); for repo in &repos { println!(" {}", repo.display); } Ok(()) } fn fuzzy_select_code(root: &str) -> Result<()> { let root = normalize_root(root)?; if !root.exists() { println!("No code directory found at {}", root.display()); return Ok(()); } let repos = discover_code_repos(&root)?; if repos.is_empty() { println!("No git repositories found in {}", root.display()); return Ok(()); } if which::which("fzf").is_err() { println!("fzf not found on PATH – install it to use fuzzy selection."); println!("Available repositories:"); for repo in &repos { println!(" {}", repo.display); } return Ok(()); } if let Some(selected) = run_fzf(&repos)? { open_in_zed(&selected.path)?; } Ok(()) } fn normalize_root(root: &str) -> Result<PathBuf> { let trimmed = root.trim(); let expanded = if trimmed.is_empty() { config::expand_path(DEFAULT_CODE_ROOT) } else { config::expand_path(trimmed) }; Ok(expanded) } struct CodeEntry { display: String, path: PathBuf, } fn discover_code_repos(root: &Path) -> Result<Vec<CodeEntry>> { let mut repos = Vec::new(); let mut seen = HashSet::new(); let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { let entries = match fs::read_dir(&dir) { Ok(entries) => entries, Err(_) => continue, }; for entry in entries.flatten() { let path = entry.path(); let file_type = match entry.file_type() { Ok(ft) => ft, Err(_) => continue, }; if !file_type.is_dir() { continue; } let name = entry.file_name().to_string_lossy().to_string(); if should_skip_dir(&name) { continue; } let git_dir = path.join(".git"); if git_dir.is_dir() || git_dir.is_file() { let display = path .strip_prefix(root) .unwrap_or(&path) .to_string_lossy() .to_string(); let key = path.to_string_lossy().to_string(); if seen.insert(key) { repos.push(CodeEntry { display, path }); } continue; } stack.push(path); } } repos.sort_by(|a, b| a.display.cmp(&b.display)); Ok(repos) } fn should_skip_dir(name: &str) -> bool { if name.starts_with('.') { return true; } matches!( name, "node_modules" | "target" | "dist" | "build" | ".git" | ".hg" | ".svn" | "__pycache__" | ".pytest_cache" | ".mypy_cache" | "venv" | ".venv" | "vendor" | "Pods" | ".cargo" | ".rustup" | ".next" | ".turbo" | ".cache" ) } fn run_fzf(entries: &[CodeEntry]) -> Result<Option<&CodeEntry>> { let mut child = Command::new("fzf") .arg("--prompt") .arg("code> ") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for entry in entries { writeln!(stdin, "{}", entry.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selection = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let selection = selection.trim(); if selection.is_empty() { return Ok(None); } Ok(entries.iter().find(|e| e.display == selection)) } fn open_in_zed(path: &Path) -> Result<()> { Command::new("open") .args(["-a", "/Applications/Zed.app"]) .arg(path) .status() .context("failed to open Zed")?; Ok(()) } fn new_project(opts: CodeNewOpts, root: &str) -> Result<()> { let root = normalize_root(root)?; let template_root = config::expand_path(DEFAULT_TEMPLATE_ROOT); let template_dir = template_root.join(opts.template.trim()); if !template_dir.exists() { bail!("Template not found: {}", template_dir.display()); } if !template_dir.is_dir() { bail!( "Template path is not a directory: {}", template_dir.display() ); } let relative = normalize_relative_path(&opts.name)?; let target = root.join(&relative); let target_display = target.display().to_string(); let mut planned_dirs = Vec::new(); if target.exists() { bail!("Destination already exists: {}", target.display()); } ensure_dir(&root, opts.dry_run, &mut planned_dirs)?; if let Some(parent) = target.parent() { if parent != root { ensure_dir(parent, opts.dry_run, &mut planned_dirs)?; } } if opts.dry_run { println!( "Would copy template {} -> {}", template_dir.display(), target_display ); if opts.ignored { if let Some((repo_root, entry)) = gitignore_entry_for_target(&target)? { println!( "Would add {} to {}", entry, repo_root.join(".gitignore").display() ); } else { bail!("--ignored requires the target to be inside a git repository"); } } return Ok(()); } copy_dir_all(&template_dir, &target)?; println!("Created {}", target_display); if opts.ignored { if let Some((repo_root, entry)) = gitignore_entry_for_target(&target)? { ensure_gitignore_entry(&repo_root, &entry)?; } else { bail!("--ignored requires the target to be inside a git repository"); } } Ok(()) } fn migrate_project(opts: CodeMigrateOpts, root: &str) -> Result<()> { let root = normalize_root(root)?; let from = normalize_path(&opts.from)?; let relative = normalize_relative_path(&opts.relative)?; let target = root.join(&relative); let target_display = target.display().to_string(); let root_display = root.to_string_lossy().to_string(); let mut planned_dirs = Vec::new(); if from == target { bail!("Source and destination are the same path."); } if !from.exists() { bail!("Source folder does not exist: {}", from.display()); } if !from.is_dir() { bail!("Source path is not a directory: {}", from.display()); } if target.exists() { bail!("Destination already exists: {}", target.display()); } if target.starts_with(&from) { bail!("Destination cannot be inside the source folder."); } ensure_dir(&root, opts.dry_run, &mut planned_dirs)?; if let Some(parent) = target.parent() { if parent.to_string_lossy() != root_display { ensure_dir(parent, opts.dry_run, &mut planned_dirs)?; } } if opts.dry_run { let action = if opts.copy { "copy" } else { "move" }; println!("Would {} {} -> {}", action, from.display(), target_display); } else if opts.copy { copy_dir_all(&from, &target)?; println!("Copied {} -> {}", from.display(), target_display); } else { move_dir(&from, &target)?; println!("Moved {} -> {}", from.display(), target_display); } if !opts.copy { let relinked = relink_bin_symlinks(&from, &target, opts.dry_run)?; if relinked > 0 { println!("Updated {} symlink(s) in ~/bin", relinked); } } let session_opts = CodeMoveSessionsOpts { from: from.to_string_lossy().to_string(), to: target.to_string_lossy().to_string(), dry_run: opts.dry_run, skip_claude: opts.skip_claude, skip_codex: opts.skip_codex, }; if opts.copy { copy_sessions(session_opts) .with_context(|| format!("copied to {}, but session copy failed", target_display))?; } else { move_sessions(session_opts).with_context(|| { format!("moved to {}, but session migration failed", target_display) })?; } Ok(()) } fn copy_sessions(opts: CodeMoveSessionsOpts) -> Result<()> { let from = normalize_path(&opts.from)?; let to = normalize_path(&opts.to)?; if from == to { bail!("Source and destination are the same path."); } let mut copied_claude = 0; let mut copied_codex = 0; let mut copied_codex_files = 0; let mut copied_zvec_docs = 0; let mut zvec_index_found = false; if !opts.skip_claude { let base = claude_projects_dir(); copied_claude = copy_project_dir(&base, &from, &to, opts.dry_run)?; } if !opts.skip_codex { let base = codex_projects_dir(); copied_codex = copy_project_dir(&base, &from, &to, opts.dry_run)?; let codex_copy = copy_codex_sessions(&from, &to, opts.dry_run)?; copied_codex_files = codex_copy.copied_files; } if let Some(zvec_path) = resolve_agent_qa_zvec_path() { let zvec_copy = copy_zvec_agent_qa_paths(&zvec_path, &from, &to, opts.dry_run)?; copied_zvec_docs = zvec_copy.copied_docs; zvec_index_found = zvec_copy.index_found; } println!("Session copy summary:"); println!(" Claude project dirs copied: {}", copied_claude); println!(" Codex legacy dirs copied: {}", copied_codex); println!(" Codex jsonl files copied: {}", copied_codex_files); if zvec_index_found { println!(" Seq zvec docs copied: {}", copied_zvec_docs); } else { println!( " Seq zvec docs copied: {} (index not found)", copied_zvec_docs ); } if opts.dry_run { println!("Dry run only; no files were changed."); } Ok(()) } fn move_sessions(opts: CodeMoveSessionsOpts) -> Result<()> { let from = normalize_path(&opts.from)?; let to = normalize_path(&opts.to)?; if from == to { bail!("Source and destination are the same path."); } let mut moved_claude = 0; let mut moved_codex = 0; let mut updated_codex_files = 0; let mut remaining_codex_files = Vec::new(); let mut updated_zvec_docs = 0; let mut zvec_index_found = false; if !opts.skip_claude { let base = claude_projects_dir(); let from_dir = base.join(path_to_project_name(&from)); let to_dir = base.join(path_to_project_name(&to)); let from_exists = from_dir.exists(); let to_exists = to_dir.exists(); moved_claude = move_project_dir(&base, &from, &to, opts.dry_run)?; if from_exists && !opts.dry_run { if from_dir.exists() { println!( "WARN Claude session dir still present: {}", from_dir.display() ); } if !to_dir.exists() && !to_exists { println!( "WARN Claude session dir missing after migration: {}", to_dir.display() ); } } } if !opts.skip_codex { let base = codex_projects_dir(); let from_dir = base.join(path_to_project_name(&from)); let to_dir = base.join(path_to_project_name(&to)); let from_exists = from_dir.exists(); let to_exists = to_dir.exists(); moved_codex = move_project_dir(&base, &from, &to, opts.dry_run)?; let codex_update = update_codex_sessions(&from, &to, opts.dry_run)?; updated_codex_files = codex_update.updated_files; remaining_codex_files = codex_update.remaining_files; if from_exists && !opts.dry_run { if from_dir.exists() { println!( "WARN Codex session dir still present: {}", from_dir.display() ); } if !to_dir.exists() && !to_exists { println!( "WARN Codex session dir missing after migration: {}", to_dir.display() ); } } } if let Some(zvec_path) = resolve_agent_qa_zvec_path() { let zvec_update = update_zvec_agent_qa_paths(&zvec_path, &from, &to, opts.dry_run)?; updated_zvec_docs = zvec_update.updated_docs; zvec_index_found = zvec_update.index_found; } println!("Session migration summary:"); println!(" Claude project dirs moved: {}", moved_claude); println!(" Codex legacy dirs moved: {}", moved_codex); println!(" Codex jsonl files updated: {}", updated_codex_files); if zvec_index_found { println!(" Seq zvec docs updated: {}", updated_zvec_docs); } else { println!( " Seq zvec docs updated: {} (index not found)", updated_zvec_docs ); } if !remaining_codex_files.is_empty() { println!("WARN Codex sessions still reference the old path:"); for path in &remaining_codex_files { println!(" {}", path.display()); } } if opts.dry_run { println!("Dry run only; no files were changed."); } Ok(()) } fn normalize_path(path: &str) -> Result<PathBuf> { let expanded = config::expand_path(path); let canonical = expanded.canonicalize().unwrap_or(expanded); Ok(canonical) } fn normalize_relative_path(path: &str) -> Result<PathBuf> { let trimmed = path.trim(); if trimmed.is_empty() { bail!("Relative path cannot be empty."); } let rel = PathBuf::from(trimmed); if rel.is_absolute() { bail!("Relative path must not be absolute."); } for component in rel.components() { if matches!(component, std::path::Component::ParentDir) { bail!("Relative path must not contain '..'."); } } Ok(rel) } fn move_dir(from: &Path, to: &Path) -> Result<()> { match fs::rename(from, to) { Ok(()) => Ok(()), Err(err) => { if is_cross_device(&err) { copy_dir_all(from, to)?; fs::remove_dir_all(from) .with_context(|| format!("failed to remove {}", from.display()))?; Ok(()) } else { Err(err).with_context(|| { format!("failed to move {} to {}", from.display(), to.display()) }) } } } } fn is_cross_device(err: &std::io::Error) -> bool { #[cfg(unix)] { err.raw_os_error() == Some(libc::EXDEV) } #[cfg(not(unix))] { let _ = err; false } } fn copy_dir_all(from: &Path, to: &Path) -> Result<()> { fs::create_dir_all(to).with_context(|| format!("failed to create {}", to.display()))?; for entry in fs::read_dir(from).with_context(|| format!("failed to read {}", from.display()))? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; let target = to.join(entry.file_name()); if target.exists() { bail!("Refusing to overwrite {}", target.display()); } if file_type.is_dir() { copy_dir_all(&path, &target)?; } else if file_type.is_file() { fs::copy(&path, &target) .with_context(|| format!("failed to copy {}", path.display()))?; } else if file_type.is_symlink() { let link_target = fs::read_link(&path) .with_context(|| format!("failed to read link {}", path.display()))?; copy_symlink(&link_target, &target)?; } } Ok(()) } fn copy_symlink(target: &Path, dest: &Path) -> Result<()> { #[cfg(unix)] { std::os::unix::fs::symlink(target, dest) .with_context(|| format!("failed to create symlink {}", dest.display()))?; return Ok(()); } #[cfg(not(unix))] { let metadata = fs::metadata(target).with_context(|| format!("failed to read {}", target.display()))?; if metadata.is_dir() { copy_dir_all(target, dest)?; } else { fs::copy(target, dest) .with_context(|| format!("failed to copy {}", target.display()))?; } Ok(()) } } fn relink_bin_symlinks(from: &Path, to: &Path, dry_run: bool) -> Result<usize> { let bin_dir = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("bin"); if !bin_dir.exists() { return Ok(0); } let mut updated = 0; for entry in fs::read_dir(&bin_dir) .with_context(|| format!("failed to read bin directory {}", bin_dir.display()))? { let entry = entry?; let path = entry.path(); let meta = fs::symlink_metadata(&path)?; if !meta.file_type().is_symlink() { continue; } let link_target = fs::read_link(&path)?; let resolved = if link_target.is_absolute() { link_target.clone() } else { path.parent().unwrap_or(&bin_dir).join(&link_target) }; if !resolved.starts_with(from) { continue; } let suffix = match resolved.strip_prefix(from) { Ok(value) => value, Err(_) => continue, }; let new_target = to.join(suffix); if dry_run { println!( "Would relink {} -> {}", path.display(), new_target.display() ); } else { relink_symlink(&path, &new_target)?; } updated += 1; } Ok(updated) } fn relink_symlink(path: &Path, target: &Path) -> Result<()> { fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?; #[cfg(unix)] { std::os::unix::fs::symlink(target, path) .with_context(|| format!("failed to create {}", path.display()))?; return Ok(()); } #[cfg(windows)] { if target.is_dir() { std::os::windows::fs::symlink_dir(target, path) .with_context(|| format!("failed to create {}", path.display()))?; } else { std::os::windows::fs::symlink_file(target, path) .with_context(|| format!("failed to create {}", path.display()))?; } return Ok(()); } #[cfg(not(any(unix, windows)))] { let _ = (path, target); Ok(()) } } fn claude_projects_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".claude") .join("projects") } fn codex_projects_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".codex") .join("projects") } fn codex_sessions_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".codex") .join("sessions") } fn resolve_agent_qa_zvec_path() -> Option<PathBuf> { match std::env::var(FLOW_AGENT_QA_ZVEC_JSONL_ENV) { Ok(raw) => { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(config::expand_path(trimmed)) } } Err(_) => Some(config::expand_path(DEFAULT_AGENT_QA_ZVEC_JSONL)), } } fn update_zvec_agent_qa_paths( zvec_jsonl: &Path, from: &Path, to: &Path, dry_run: bool, ) -> Result<ZvecMoveSummary> { if !zvec_jsonl.exists() { return Ok(ZvecMoveSummary { updated_docs: 0, index_found: false, }); } let from_str = from.to_string_lossy().to_string(); let to_str = to.to_string_lossy().to_string(); let from_project_key = path_to_project_name(from); let to_project_key = path_to_project_name(to); let input = fs::File::open(zvec_jsonl) .with_context(|| format!("failed to read {}", zvec_jsonl.display()))?; let reader = BufReader::new(input); let tmp_path = zvec_jsonl.with_extension("jsonl.tmp"); let mut writer = if dry_run { None } else { Some(BufWriter::new(fs::File::create(&tmp_path).with_context( || format!("failed to write {}", tmp_path.display()), )?)) }; let mut updated_docs = 0; for line in reader.lines() { let line = line.with_context(|| format!("failed to read line from {}", zvec_jsonl.display()))?; let mut output_line = line.clone(); if line.contains(&from_str) || line.contains(&from_project_key) { if let Ok(mut value) = serde_json::from_str::<Value>(&line) { if rewrite_zvec_doc_paths( &mut value, &from_str, &to_str, &from_project_key, &to_project_key, ) { output_line = serde_json::to_string(&value)?; updated_docs += 1; } } } if let Some(writer) = writer.as_mut() { writer.write_all(output_line.as_bytes())?; writer.write_all(b"\n")?; } } if let Some(mut writer) = writer { writer.flush()?; fs::rename(&tmp_path, zvec_jsonl) .with_context(|| format!("failed to replace {}", zvec_jsonl.display()))?; } Ok(ZvecMoveSummary { updated_docs, index_found: true, }) } fn copy_zvec_agent_qa_paths( zvec_jsonl: &Path, from: &Path, to: &Path, dry_run: bool, ) -> Result<ZvecCopySummary> { if !zvec_jsonl.exists() { return Ok(ZvecCopySummary { copied_docs: 0, index_found: false, }); } let from_str = from.to_string_lossy().to_string(); let to_str = to.to_string_lossy().to_string(); let from_project_key = path_to_project_name(from); let to_project_key = path_to_project_name(to); let input = fs::File::open(zvec_jsonl) .with_context(|| format!("failed to read {}", zvec_jsonl.display()))?; let reader = BufReader::new(input); let tmp_path = zvec_jsonl.with_extension("jsonl.tmp"); let mut writer = if dry_run { None } else { Some(BufWriter::new(fs::File::create(&tmp_path).with_context( || format!("failed to write {}", tmp_path.display()), )?)) }; let mut copied_docs = 0; for line in reader.lines() { let line = line.with_context(|| format!("failed to read line from {}", zvec_jsonl.display()))?; if let Some(writer) = writer.as_mut() { writer.write_all(line.as_bytes())?; writer.write_all(b"\n")?; } if !line.contains(&from_str) && !line.contains(&from_project_key) { continue; } let Ok(mut value) = serde_json::from_str::<Value>(&line) else { continue; }; if !rewrite_zvec_doc_paths( &mut value, &from_str, &to_str, &from_project_key, &to_project_key, ) { continue; } let old_id = value.get("id").and_then(|v| v.as_str()); let new_id = derive_copy_doc_id(old_id, &line, &to_str); if let Some(obj) = value.as_object_mut() { obj.insert("id".to_string(), Value::String(new_id)); } copied_docs += 1; if let Some(writer) = writer.as_mut() { let copied_line = serde_json::to_string(&value)?; writer.write_all(copied_line.as_bytes())?; writer.write_all(b"\n")?; } } if let Some(mut writer) = writer { writer.flush()?; fs::rename(&tmp_path, zvec_jsonl) .with_context(|| format!("failed to replace {}", zvec_jsonl.display()))?; } Ok(ZvecCopySummary { copied_docs, index_found: true, }) } fn rewrite_zvec_doc_paths( doc: &mut Value, from_path: &str, to_path: &str, from_project_key: &str, to_project_key: &str, ) -> bool { let Some(root) = doc.as_object_mut() else { return false; }; let Some(meta_value) = root.get_mut("metadata") else { return false; }; let Some(meta) = meta_value.as_object_mut() else { return false; }; let mut changed = false; if let Some(project_path) = meta.get("project_path").and_then(|v| v.as_str()) { if let Some(rewritten) = rewrite_path_prefix(project_path, from_path, to_path) { meta.insert("project_path".to_string(), Value::String(rewritten)); changed = true; } } if let Some(source_path) = meta.get("source_path").and_then(|v| v.as_str()) { if let Some(rewritten) = rewrite_project_source_path(source_path, from_project_key, to_project_key) { meta.insert("source_path".to_string(), Value::String(rewritten)); changed = true; } } changed } fn rewrite_path_prefix(value: &str, from: &str, to: &str) -> Option<String> { if value == from { return Some(to.to_string()); } let prefix = format!("{from}/"); if let Some(suffix) = value.strip_prefix(&prefix) { return Some(format!("{to}/{suffix}")); } None } fn rewrite_project_source_path( source_path: &str, from_project_key: &str, to_project_key: &str, ) -> Option<String> { for marker in ["/.claude/projects/", "/.codex/projects/"] { let old = format!("{marker}{from_project_key}"); if source_path.contains(&old) { let new = format!("{marker}{to_project_key}"); return Some(source_path.replacen(&old, &new, 1)); } } None } fn derive_copy_doc_id(existing_id: Option<&str>, line: &str, to: &str) -> String { if let Some(id) = existing_id { return derive_copy_id(id, to); } let mut hasher = DefaultHasher::new(); line.hash(&mut hasher); to.hash(&mut hasher); let hash = hasher.finish(); format!("agent-qa-copy-{hash:x}") } fn move_project_dir(base: &Path, from: &Path, to: &Path, dry_run: bool) -> Result<usize> { if !base.exists() { return Ok(0); } let from_name = path_to_project_name(from); let to_name = path_to_project_name(to); let from_dir = base.join(&from_name); let to_dir = base.join(&to_name); if !from_dir.exists() { return Ok(0); } if to_dir.exists() { println!("Skip: {} already exists", to_dir.display()); return Ok(0); } if dry_run { println!("Would move {} -> {}", from_dir.display(), to_dir.display()); } else { if let Some(parent) = to_dir.parent() { fs::create_dir_all(parent)?; } fs::rename(&from_dir, &to_dir).with_context(|| { format!( "failed to move {} to {}", from_dir.display(), to_dir.display() ) })?; } Ok(1) } fn copy_project_dir(base: &Path, from: &Path, to: &Path, dry_run: bool) -> Result<usize> { if !base.exists() { return Ok(0); } let from_name = path_to_project_name(from); let to_name = path_to_project_name(to); let from_dir = base.join(&from_name); let to_dir = base.join(&to_name); if !from_dir.exists() { return Ok(0); } if to_dir.exists() { println!("Skip: {} already exists", to_dir.display()); return Ok(0); } if dry_run { println!("Would copy {} -> {}", from_dir.display(), to_dir.display()); } else { if let Some(parent) = to_dir.parent() { fs::create_dir_all(parent)?; } copy_dir_all(&from_dir, &to_dir)?; } Ok(1) } fn path_to_project_name(path: &Path) -> String { path.to_string_lossy().replace('/', "-") } struct CodexCopySummary { copied_files: usize, } fn copy_codex_sessions(from: &Path, to: &Path, dry_run: bool) -> Result<CodexCopySummary> { let root = codex_sessions_dir(); if !root.exists() { return Ok(CodexCopySummary { copied_files: 0 }); } let from_str = from.to_string_lossy().to_string(); let to_str = to.to_string_lossy().to_string(); let mut copied_files = 0; try_for_each_codex_session_file(&root, |file_path| { if let Some(copy_path) = copy_codex_session_file(&file_path, &from_str, &to_str, dry_run)? { copied_files += 1; if dry_run { println!( "Would copy session {} -> {}", file_path.display(), copy_path.display() ); } } Ok(()) })?; Ok(CodexCopySummary { copied_files }) } struct CodexUpdateSummary { updated_files: usize, remaining_files: Vec<PathBuf>, } fn copy_codex_session_file( path: &Path, from: &str, to: &str, dry_run: bool, ) -> Result<Option<PathBuf>> { let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; let ends_with_newline = content.ends_with('\n'); let mut matched = false; let mut old_id: Option<String> = None; let mut parsed_lines: Vec<(String, Option<Value>)> = Vec::new(); for line in content.lines() { if line.trim().is_empty() { parsed_lines.push((String::new(), None)); continue; } if !line.contains("\"session_meta\"") { parsed_lines.push((line.to_string(), None)); continue; } match serde_json::from_str::<Value>(line) { Ok(value) => { if value.get("type").and_then(|v| v.as_str()) == Some("session_meta") { if let Some(payload) = value.get("payload").and_then(|v| v.as_object()) { if old_id.is_none() { old_id = payload .get("id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } if payload.get("cwd").and_then(|v| v.as_str()) == Some(from) { matched = true; } } } parsed_lines.push((line.to_string(), Some(value))); } Err(_) => parsed_lines.push((line.to_string(), None)), } } if !matched { return Ok(None); } let fallback_id = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("session") .to_string(); let old_id = old_id.unwrap_or(fallback_id); let new_id = derive_copy_id(&old_id, to); let copy_path = derive_copy_path(path, &old_id, &new_id); let mut lines = Vec::new(); for (raw, value) in parsed_lines { if let Some(mut value) = value { if value.get("type").and_then(|v| v.as_str()) == Some("session_meta") { if let Some(payload) = value.get_mut("payload") { if let Some(obj) = payload.as_object_mut() { if obj.get("cwd").and_then(|v| v.as_str()) == Some(from) { obj.insert("cwd".to_string(), Value::String(to.to_string())); obj.insert("id".to_string(), Value::String(new_id.clone())); lines.push(serde_json::to_string(&value)?); continue; } } } } } lines.push(raw); } if dry_run { return Ok(Some(copy_path)); } let mut output = lines.join("\n"); if ends_with_newline { output.push('\n'); } fs::write(©_path, output.as_bytes()) .with_context(|| format!("failed to write {}", copy_path.display()))?; Ok(Some(copy_path)) } fn derive_copy_id(old_id: &str, to: &str) -> String { let mut hasher = DefaultHasher::new(); old_id.hash(&mut hasher); to.hash(&mut hasher); let hash = hasher.finish(); format!("{old_id}-copy-{hash:x}") } fn derive_copy_path(path: &Path, old_id: &str, new_id: &str) -> PathBuf { let parent = path.parent().unwrap_or_else(|| Path::new(".")); let filename = path .file_name() .and_then(|s| s.to_str()) .unwrap_or("session.jsonl"); let base_name = if filename.contains(old_id) { filename.replace(old_id, new_id) } else { let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("session"); format!("{stem}-{new_id}.jsonl") }; unique_copy_path(parent, &base_name) } fn unique_copy_path(parent: &Path, base_name: &str) -> PathBuf { let mut candidate = parent.join(base_name); if !candidate.exists() { return candidate; } for i in 1..=1000 { let alt = insert_suffix(base_name, &format!("-{}", i)); candidate = parent.join(&alt); if !candidate.exists() { return candidate; } } candidate } fn insert_suffix(filename: &str, suffix: &str) -> String { if let Some(idx) = filename.rfind('.') { format!("{}{}{}", &filename[..idx], suffix, &filename[idx..]) } else { format!("{}{}", filename, suffix) } } fn update_codex_sessions(from: &Path, to: &Path, dry_run: bool) -> Result<CodexUpdateSummary> { let root = codex_sessions_dir(); if !root.exists() { return Ok(CodexUpdateSummary { updated_files: 0, remaining_files: Vec::new(), }); } let from_str = from.to_string_lossy().to_string(); let to_str = to.to_string_lossy().to_string(); let mut updated_files = 0; let mut remaining_files = Vec::new(); try_for_each_codex_session_file(&root, |file_path| { let result = update_codex_session_file(&file_path, &from_str, &to_str, dry_run)?; if result.updated { updated_files += 1; } if result.remaining { remaining_files.push(file_path); } Ok(()) })?; Ok(CodexUpdateSummary { updated_files, remaining_files, }) } fn try_for_each_codex_session_file( root: &Path, mut visit: impl FnMut(PathBuf) -> Result<()>, ) -> Result<()> { let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { let entries = match fs::read_dir(&dir) { Ok(v) => v, Err(_) => continue, }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.extension().map(|e| e == "jsonl").unwrap_or(false) { visit(path)?; } } } Ok(()) } struct CodexFileUpdate { updated: bool, remaining: bool, } fn update_codex_session_file( path: &Path, from: &str, to: &str, dry_run: bool, ) -> Result<CodexFileUpdate> { let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; let mut changed = false; let mut matched = false; let mut lines = Vec::new(); let ends_with_newline = content.ends_with('\n'); for line in content.lines() { if line.trim().is_empty() { lines.push(String::new()); continue; } if !line.contains("\"session_meta\"") { lines.push(line.to_string()); continue; } match serde_json::from_str::<Value>(line) { Ok(mut value) => { let mut updated_line = false; if value.get("type").and_then(|v| v.as_str()) == Some("session_meta") { if let Some(payload) = value.get_mut("payload") { if let Some(obj) = payload.as_object_mut() { if obj.get("cwd").and_then(|v| v.as_str()) == Some(from) { matched = true; obj.insert("cwd".to_string(), Value::String(to.to_string())); updated_line = true; } } } } if updated_line { changed = true; lines.push(serde_json::to_string(&value)?); } else { lines.push(line.to_string()); } } Err(_) => lines.push(line.to_string()), } } if !changed { let remaining = if matched && !dry_run { file_has_session_meta_cwd(path, from)? } else { false }; return Ok(CodexFileUpdate { updated: false, remaining, }); } if dry_run { println!("Would update {}", path.display()); return Ok(CodexFileUpdate { updated: true, remaining: true, }); } let mut output = lines.join("\n"); if ends_with_newline { output.push('\n'); } let tmp_path = path.with_extension("jsonl.tmp"); fs::write(&tmp_path, output.as_bytes()) .with_context(|| format!("failed to write {}", tmp_path.display()))?; fs::rename(&tmp_path, path).with_context(|| format!("failed to replace {}", path.display()))?; let remaining = file_has_session_meta_cwd(path, from)?; Ok(CodexFileUpdate { updated: true, remaining, }) } fn file_has_session_meta_cwd(path: &Path, from: &str) -> Result<bool> { let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; Ok(content .lines() .any(|line| session_meta_cwd_matches(line, from))) } fn session_meta_cwd_matches(line: &str, from: &str) -> bool { if line.trim().is_empty() { return false; } if !line.contains("\"session_meta\"") { return false; } let Ok(value) = serde_json::from_str::<Value>(line) else { return false; }; if value.get("type").and_then(|v| v.as_str()) != Some("session_meta") { return false; } let Some(payload) = value.get("payload") else { return false; }; let Some(obj) = payload.as_object() else { return false; }; obj.get("cwd").and_then(|v| v.as_str()) == Some(from) } fn ensure_dir(path: &Path, dry_run: bool, planned: &mut Vec<PathBuf>) -> Result<()> { if path.exists() { return Ok(()); } if planned.iter().any(|p| p == path) { return Ok(()); } if dry_run { println!("Would create {}", path.display()); planned.push(path.to_path_buf()); return Ok(()); } fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))?; planned.push(path.to_path_buf()); Ok(()) } fn gitignore_entry_for_target(target: &Path) -> Result<Option<(PathBuf, String)>> { let root = find_git_root(target)?; let Some(repo_root) = root else { return Ok(None); }; let relative = target .strip_prefix(&repo_root) .unwrap_or(target) .to_string_lossy() .replace('\\', "/"); let mut entry = relative.trim().trim_start_matches("./").to_string(); if entry.is_empty() { return Ok(None); } if !entry.ends_with('/') { entry.push('/'); } Ok(Some((repo_root, entry))) } fn find_git_root(start: &Path) -> Result<Option<PathBuf>> { let mut current = start.to_path_buf(); if !current.exists() { if let Some(parent) = current.parent() { current = parent.to_path_buf(); } } loop { let git_dir = current.join(".git"); if git_dir.is_dir() || git_dir.is_file() { return Ok(Some(current)); } if !current.pop() { return Ok(None); } } } fn ensure_gitignore_entry(repo_root: &Path, entry: &str) -> Result<()> { let gitignore = repo_root.join(".gitignore"); let entry_trimmed = entry.trim().trim_end_matches('/'); let entry_with_slash = format!("{}/", entry_trimmed); let mut existing = String::new(); if gitignore.exists() { existing = fs::read_to_string(&gitignore) .with_context(|| format!("failed to read {}", gitignore.display()))?; if existing.lines().any(|line| { let trimmed = line.trim(); trimmed == entry_trimmed || trimmed == entry_with_slash }) { return Ok(()); } } let mut output = existing; if !output.is_empty() && !output.ends_with('\n') { output.push('\n'); } output.push_str(&entry_with_slash); output.push('\n'); fs::write(&gitignore, output.as_bytes()) .with_context(|| format!("failed to write {}", gitignore.display()))?; Ok(()) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn rewrite_path_prefix_rewrites_exact_and_nested_paths() { let from = "~/code/org/linsa/linsa-mac"; let to = "~/code/org/linsa/linsa-native"; assert_eq!(rewrite_path_prefix(from, from, to), Some(to.to_string())); assert_eq!( rewrite_path_prefix("~/code/org/linsa/linsa-mac/src/ui", from, to), Some("~/code/org/linsa/linsa-native/src/ui".to_string()) ); assert_eq!( rewrite_path_prefix("~/code/org/linsa/linsa", from, to), None ); } #[test] fn rewrite_zvec_doc_paths_updates_project_and_source_paths() { let from = "~/code/org/linsa/linsa-mac"; let to = "~/code/org/linsa/linsa-native"; let from_key = path_to_project_name(Path::new(from)); let to_key = path_to_project_name(Path::new(to)); let mut value = serde_json::json!({ "id": "doc-1", "text": "Question: q\n\nAnswer: a", "metadata": { "project_path": "~/code/org/linsa/linsa-mac/src", "source_path": format!( "~/.claude/projects/{from_key}/session.jsonl" ) } }); let changed = rewrite_zvec_doc_paths(&mut value, from, to, &from_key, &to_key); assert!(changed); assert_eq!( value .get("metadata") .and_then(|m| m.get("project_path")) .and_then(|v| v.as_str()), Some("~/code/org/linsa/linsa-native/src") ); assert_eq!( value .get("metadata") .and_then(|m| m.get("source_path")) .and_then(|v| v.as_str()), Some(format!("~/.claude/projects/{to_key}/session.jsonl").as_str()) ); } #[test] fn zvec_move_and_copy_update_project_scope() -> Result<()> { let tmp = tempdir()?; let zvec_path = tmp.path().join("agent_qa.jsonl"); let from = Path::new("~/code/org/linsa/linsa-mac"); let to = Path::new("~/code/org/linsa/linsa-native"); let from_key = path_to_project_name(from); let row = serde_json::json!({ "id": "doc-1", "text": "Question: q\n\nAnswer: a", "metadata": { "agent": "codex", "session_id": "s1", "project_path": "~/code/org/linsa/linsa-mac", "source_path": format!("~/.claude/projects/{from_key}/s1.jsonl"), "ts_ms": 1 } }); fs::write(&zvec_path, format!("{}\n", serde_json::to_string(&row)?))?; let move_summary = update_zvec_agent_qa_paths(&zvec_path, from, to, false)?; assert_eq!(move_summary.updated_docs, 1); let moved = fs::read_to_string(&zvec_path)?; assert!(moved.contains("~/code/org/linsa/linsa-native")); assert!(!moved.contains("~/code/org/linsa/linsa-mac\"")); fs::write(&zvec_path, format!("{}\n", serde_json::to_string(&row)?))?; let copy_summary = copy_zvec_agent_qa_paths(&zvec_path, from, to, false)?; assert_eq!(copy_summary.copied_docs, 1); let copied = fs::read_to_string(&zvec_path)?; assert!(copied.contains("~/code/org/linsa/linsa-mac")); assert!(copied.contains("~/code/org/linsa/linsa-native")); assert!(copied.contains("doc-1-copy-")); Ok(()) } } ================================================ FILE: src/codex_memory.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; use ignore::WalkBuilder; use rusqlite::{Connection, params}; use serde::Serialize; use sha2::{Digest, Sha256}; use crate::codex_skill_eval::{self, CodexSkillEvalEvent, CodexSkillOutcomeEvent}; use crate::{ai, codex_text, config, jazz_state, repo_capsule}; const MEMORY_ROOT_ENV: &str = "FLOW_CODEX_MEMORY_ROOT"; const REPO_SYMBOL_INDEX_KIND: &str = "repo_symbols"; const REPO_SYMBOL_INDEX_VERSION: u32 = 1; const REPO_SESSION_INDEX_KIND: &str = "repo_sessions"; const REPO_SESSION_INDEX_VERSION: u32 = 1; const MAX_SYMBOL_FILES: usize = 24; const MAX_SYMBOLS_PER_FILE: usize = 8; const MAX_SYMBOL_FILE_BYTES: usize = 256 * 1024; const MAX_SNIPPET_LINES: usize = 4; const MAX_SNIPPET_CHARS: usize = 220; const MAX_SESSION_THREADS: usize = 6; const MAX_SESSION_EXCHANGES_PER_THREAD: usize = 2; const MAX_SESSION_TEXT_CHARS: usize = 240; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexMemoryStats { pub root_dir: String, pub db_path: String, pub total_events: usize, pub total_facts: usize, pub skill_eval_events: usize, pub skill_eval_outcomes: usize, pub latest_recorded_at_unix: Option<u64>, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexMemoryRecentEntry { pub event_kind: String, pub recorded_at_unix: u64, pub target_path: Option<String>, pub session_id: Option<String>, pub route: Option<String>, pub query: Option<String>, pub success: Option<f64>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexMemorySyncSummary { pub total_considered: usize, pub inserted: usize, pub skipped: usize, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexMemoryFactHit { pub fact_kind: String, pub title: String, pub body: String, pub path_hint: Option<String>, pub source_tag: String, pub score: f64, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexMemoryCodeHit { pub path: String, pub score: f64, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexMemorySnippetHit { pub path: String, pub symbol: String, pub snippet: String, pub score: f64, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexMemoryQueryResult { pub repo_root: String, pub query: String, pub facts: Vec<CodexMemoryFactHit>, pub code_paths: Vec<CodexMemoryCodeHit>, pub snippets: Vec<CodexMemorySnippetHit>, pub rendered: String, } #[derive(Debug, Clone, PartialEq, Eq)] struct RepoSymbolFact { fact_kind: &'static str, title: String, body: String, path_hint: String, } #[derive(Debug, Clone)] struct QueryProfile { tokens: Vec<String>, code_intent: bool, docs_intent: bool, explicit_paths: Vec<String>, } pub fn root_dir() -> PathBuf { if let Ok(path) = std::env::var(MEMORY_ROOT_ENV) { let trimmed = path.trim(); if !trimmed.is_empty() { return config::expand_path(trimmed); } } jazz_state::state_dir().join("codex-memory") } pub fn db_path() -> PathBuf { root_dir().join("memory.sqlite") } pub fn mirror_skill_eval_event(event: &CodexSkillEvalEvent) -> Result<bool> { let mut sanitized = event.clone(); let Some(query) = codex_text::sanitize_codex_query_text(&sanitized.query) else { return Ok(false); }; sanitized.query = query; let payload = serde_json::to_string(&sanitized).context("failed to encode skill-eval event")?; let conn = open_connection()?; insert_marshaled( &conn, "skill_eval_event", sanitized.recorded_at_unix, Some(sanitized.target_path.as_str()), sanitized.session_id.as_deref(), sanitized.runtime_token.as_deref(), Some(sanitized.route.as_str()), Some(sanitized.query.as_str()), None, &payload, ) } pub fn mirror_skill_outcome_event(outcome: &CodexSkillOutcomeEvent) -> Result<bool> { let payload = serde_json::to_string(outcome).context("failed to encode skill outcome")?; let conn = open_connection()?; insert_marshaled( &conn, "skill_eval_outcome", outcome.recorded_at_unix, outcome.target_path.as_deref(), outcome.session_id.as_deref(), outcome.runtime_token.as_deref(), None, None, Some(outcome.success), &payload, ) } pub fn stats() -> Result<CodexMemoryStats> { let conn = open_connection()?; let mut stmt = conn.prepare( "SELECT \ COUNT(*), \ (SELECT COUNT(*) FROM codex_memory_facts), \ COALESCE(SUM(CASE WHEN event_kind = 'skill_eval_event' THEN 1 ELSE 0 END), 0), \ COALESCE(SUM(CASE WHEN event_kind = 'skill_eval_outcome' THEN 1 ELSE 0 END), 0), \ MAX(recorded_at_unix) \ FROM codex_memory_events", )?; let (total, facts, evals, outcomes, latest): (i64, i64, i64, i64, Option<i64>) = stmt .query_row([], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, )) })?; Ok(CodexMemoryStats { root_dir: root_dir().display().to_string(), db_path: db_path().display().to_string(), total_events: total.max(0) as usize, total_facts: facts.max(0) as usize, skill_eval_events: evals.max(0) as usize, skill_eval_outcomes: outcomes.max(0) as usize, latest_recorded_at_unix: latest.map(|value| value.max(0) as u64), }) } pub fn recent(target_path: Option<&Path>, limit: usize) -> Result<Vec<CodexMemoryRecentEntry>> { let conn = open_connection()?; let mut rows = Vec::new(); if let Some(target_path) = target_path { let target = target_path.display().to_string(); let target_prefix = format!("{}/%", target.trim_end_matches('/')); let mut stmt = conn.prepare( "SELECT event_kind, recorded_at_unix, target_path, session_id, route, query, success \ FROM codex_memory_events \ WHERE target_path = ?1 OR target_path LIKE ?2 \ ORDER BY recorded_at_unix DESC \ LIMIT ?3", )?; let mut query = stmt.query(params![target, target_prefix, limit as i64])?; while let Some(row) = query.next()? { rows.push(map_recent_entry(row)?); } } else { let mut stmt = conn.prepare( "SELECT event_kind, recorded_at_unix, target_path, session_id, route, query, success \ FROM codex_memory_events \ ORDER BY recorded_at_unix DESC \ LIMIT ?1", )?; let mut query = stmt.query(params![limit as i64])?; while let Some(row) = query.next()? { rows.push(map_recent_entry(row)?); } } Ok(rows) } pub fn sync_from_skill_eval_logs(limit: usize) -> Result<CodexMemorySyncSummary> { let mut total_considered = 0usize; let mut inserted = 0usize; for event in codex_skill_eval::load_events(None, limit)? { total_considered += 1; if mirror_skill_eval_event(&event)? { inserted += 1; } } for outcome in codex_skill_eval::load_outcomes(None, limit)? { total_considered += 1; if mirror_skill_outcome_event(&outcome)? { inserted += 1; } } Ok(CodexMemorySyncSummary { total_considered, inserted, skipped: total_considered.saturating_sub(inserted), }) } pub fn sync_repo_capsule_for_path(path: &Path) -> Result<usize> { let capsule = repo_capsule::load_or_refresh_capsule_for_path(path)?; mirror_repo_capsule(&capsule) } pub fn mirror_repo_capsule(capsule: &repo_capsule::RepoCapsule) -> Result<usize> { let conn = open_connection()?; let mut changes = 0usize; let repo_root = capsule.repo_root.as_str(); let updated_at_unix = capsule.updated_at_unix; changes += upsert_fact( &conn, repo_root, "summary", &format!("Summary for {}", capsule.repo_id), &capsule.summary, None, "repo_capsule", updated_at_unix, )?; if !capsule.languages.is_empty() { changes += upsert_fact( &conn, repo_root, "languages", &format!("Languages in {}", capsule.repo_id), &capsule.languages.join(", "), None, "repo_capsule", updated_at_unix, )?; } if !capsule.manifests.is_empty() { changes += upsert_fact( &conn, repo_root, "manifests", &format!("Manifests in {}", capsule.repo_id), &capsule.manifests.join(", "), None, "repo_capsule", updated_at_unix, )?; } for command in &capsule.commands { changes += upsert_fact( &conn, repo_root, "command", &format!("Command: {}", command), &format!("Use `{command}` in {}", capsule.repo_id), None, "repo_capsule", updated_at_unix, )?; } for path in &capsule.important_paths { changes += upsert_fact( &conn, repo_root, "important_path", &format!("Important path: {}", path), &format!("Key file or directory in {}: {}", capsule.repo_id, path), Some(path), "repo_capsule", updated_at_unix, )?; } for hint in &capsule.docs_hints { changes += upsert_fact( &conn, repo_root, "docs_hint", &format!("Docs hint for {}", capsule.repo_id), hint, None, "repo_capsule", updated_at_unix, )?; } changes += sync_repo_symbol_facts(&conn, capsule)?; if let Ok(session_changes) = sync_repo_session_facts(&conn, capsule) { changes += session_changes; } Ok(changes) } pub fn query_repo_facts( path: &Path, query: &str, limit: usize, ) -> Result<Option<CodexMemoryQueryResult>> { let capsule = repo_capsule::load_or_refresh_capsule_for_path(path)?; let _ = mirror_repo_capsule(&capsule); let profile = build_query_profile(query); let conn = open_connection()?; let mut stmt = conn.prepare( "SELECT fact_kind, title, body, path_hint, source_tag \ FROM codex_memory_facts \ WHERE target_path = ?1 \ ORDER BY updated_at_unix DESC", )?; let mut rows = stmt.query(params![capsule.repo_root.as_str()])?; let mut hits = Vec::new(); while let Some(row) = rows.next()? { let fact_kind: String = row.get(0)?; let title: String = row.get(1)?; let body: String = row.get(2)?; let path_hint: Option<String> = row.get(3)?; let source_tag: String = row.get(4)?; let score = fact_score(&profile, &fact_kind, &title, &body, path_hint.as_deref()); if score <= 0.0 { continue; } hits.push(CodexMemoryFactHit { fact_kind, title, body, path_hint, source_tag, score, }); } hits.sort_by(|a, b| b.score.total_cmp(&a.score)); let mut code_paths = search_code_paths(Path::new(&capsule.repo_root), &profile, limit); let dynamic_symbols = search_symbols_for_code_paths(Path::new(&capsule.repo_root), &code_paths, &profile, limit); merge_dynamic_symbol_hits(&mut hits, dynamic_symbols); hits.sort_by(|a, b| b.score.total_cmp(&a.score)); hits.truncate(limit); if hits.is_empty() && code_paths.is_empty() { return Ok(None); } let snippets = extract_symbol_snippets(Path::new(&capsule.repo_root), &hits, 2); if !hits.is_empty() { let hinted_paths: std::collections::BTreeSet<_> = hits .iter() .filter_map(|hit| hit.path_hint.as_deref()) .collect(); code_paths.retain(|hit| !hinted_paths.contains(hit.path.as_str())); } let rendered = render_query_result( &capsule.repo_root, query, &profile, &hits, &code_paths, &snippets, ); Ok(Some(CodexMemoryQueryResult { repo_root: capsule.repo_root, query: query.trim().to_string(), facts: hits, code_paths, snippets, rendered, })) } fn open_connection() -> Result<Connection> { open_connection_at(&db_path()) } fn open_connection_at(path: &Path) -> Result<Connection> { let parent = path .parent() .ok_or_else(|| anyhow::anyhow!("missing parent for {}", path.display()))?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; let conn = Connection::open(path).with_context(|| format!("failed to open {}", path.display()))?; conn.busy_timeout(Duration::from_millis(1500))?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "temp_store", "MEMORY")?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS codex_memory_events ( event_key TEXT PRIMARY KEY, event_kind TEXT NOT NULL, recorded_at_unix INTEGER NOT NULL, target_path TEXT, session_id TEXT, runtime_token TEXT, route TEXT, query TEXT, success REAL, payload_json TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_codex_memory_events_target_time ON codex_memory_events(target_path, recorded_at_unix DESC); CREATE INDEX IF NOT EXISTS idx_codex_memory_events_session_time ON codex_memory_events(session_id, recorded_at_unix DESC); CREATE INDEX IF NOT EXISTS idx_codex_memory_events_kind_time ON codex_memory_events(event_kind, recorded_at_unix DESC); CREATE TABLE IF NOT EXISTS codex_memory_facts ( fact_key TEXT PRIMARY KEY, target_path TEXT NOT NULL, fact_kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, path_hint TEXT, source_tag TEXT NOT NULL, updated_at_unix INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_codex_memory_facts_target_time ON codex_memory_facts(target_path, updated_at_unix DESC); CREATE INDEX IF NOT EXISTS idx_codex_memory_facts_kind ON codex_memory_facts(fact_kind); CREATE TABLE IF NOT EXISTS codex_memory_indexes ( target_path TEXT NOT NULL, index_kind TEXT NOT NULL, version INTEGER NOT NULL, source_updated_at_unix INTEGER NOT NULL, updated_at_unix INTEGER NOT NULL, PRIMARY KEY(target_path, index_kind) );", )?; Ok(conn) } fn insert_marshaled( conn: &Connection, event_kind: &str, recorded_at_unix: u64, target_path: Option<&str>, session_id: Option<&str>, runtime_token: Option<&str>, route: Option<&str>, query: Option<&str>, success: Option<f64>, payload_json: &str, ) -> Result<bool> { let key = event_key(event_kind, payload_json); let changed = conn.execute( "INSERT OR IGNORE INTO codex_memory_events ( event_key, event_kind, recorded_at_unix, target_path, session_id, runtime_token, route, query, success, payload_json ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ key, event_kind, recorded_at_unix as i64, target_path, session_id, runtime_token, route, query, success, payload_json ], )?; Ok(changed > 0) } fn event_key(event_kind: &str, payload_json: &str) -> String { let mut hasher = Sha256::new(); hasher.update(event_kind.as_bytes()); hasher.update([0u8]); hasher.update(payload_json.as_bytes()); format!("{:x}", hasher.finalize()) } fn fact_key( target_path: &str, fact_kind: &str, title: &str, body: &str, path_hint: Option<&str>, ) -> String { let mut hasher = Sha256::new(); hasher.update(target_path.as_bytes()); hasher.update([0u8]); hasher.update(fact_kind.as_bytes()); hasher.update([0u8]); hasher.update(title.as_bytes()); hasher.update([0u8]); hasher.update(body.as_bytes()); hasher.update([0u8]); hasher.update(path_hint.unwrap_or("").as_bytes()); format!("{:x}", hasher.finalize()) } fn upsert_fact( conn: &Connection, target_path: &str, fact_kind: &str, title: &str, body: &str, path_hint: Option<&str>, source_tag: &str, updated_at_unix: u64, ) -> Result<usize> { let key = fact_key(target_path, fact_kind, title, body, path_hint); let changed = conn.execute( "INSERT INTO codex_memory_facts ( fact_key, target_path, fact_kind, title, body, path_hint, source_tag, updated_at_unix ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(fact_key) DO UPDATE SET target_path = excluded.target_path, fact_kind = excluded.fact_kind, title = excluded.title, body = excluded.body, path_hint = excluded.path_hint, source_tag = excluded.source_tag, updated_at_unix = excluded.updated_at_unix", params![ key, target_path, fact_kind, title, body, path_hint, source_tag, updated_at_unix as i64, ], )?; Ok(changed) } fn fact_score( profile: &QueryProfile, fact_kind: &str, title: &str, body: &str, path_hint: Option<&str>, ) -> f64 { if profile.tokens.is_empty() && profile.explicit_paths.is_empty() { return 0.0; } let title_lower = title.to_ascii_lowercase(); let body_lower = body.to_ascii_lowercase(); let path_lower = path_hint.unwrap_or("").to_ascii_lowercase(); let kind_lower = fact_kind.to_ascii_lowercase(); let mut score = 0.0; for token in &profile.tokens { if title_lower.contains(token.as_str()) { score += 3.0; } if body_lower.contains(token.as_str()) { score += 1.5; } if path_lower.contains(token.as_str()) { score += 2.0; } if kind_lower.contains(token.as_str()) { score += 0.5; } } for explicit_path in &profile.explicit_paths { if path_lower == *explicit_path { score += 10.0; } else if path_lower.contains(explicit_path) { score += 6.0; } } if profile.code_intent { match fact_kind { "symbol" => score += 5.0, "entrypoint" => score += 3.0, "session_exchange" => score += 2.5, "session_intent" => score += 1.5, "session_recent" => score += 1.0, "important_path" | "command" => score += 1.5, "doc_heading" | "docs_hint" | "summary" => score -= 2.0, _ => {} } if path_lower.starts_with("src/") || path_lower.ends_with(".rs") || path_lower.ends_with(".ts") { score += 1.0; } } if profile.docs_intent { match fact_kind { "doc_heading" => score += 4.0, "docs_hint" | "summary" => score += 2.0, "session_recent" => score += 0.5, "symbol" => score -= 2.0, "session_exchange" => score -= 1.0, _ => {} } if path_lower.starts_with("docs/") || path_lower.ends_with(".md") || path_lower.ends_with(".mdx") { score += 1.5; } } score } fn tokenize_query(query: &str) -> Vec<String> { query .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-' && ch != '/') .filter(|part| !part.is_empty()) .map(|part| part.to_ascii_lowercase()) .filter(|part| { part.len() >= 3 && !matches!( part.as_str(), "see" | "with" | "this" | "that" | "from" | "into" | "repo" | "code" | "work" | "what" | "latest" | "codex" ) }) .collect() } fn build_query_profile(query: &str) -> QueryProfile { let query_lower = query.to_ascii_lowercase(); let tokens = tokenize_query(query); let code_intent = tokens.iter().any(|token| { matches!( token.as_str(), "implement" | "fix" | "refactor" | "edit" | "change" | "update" | "patch" | "function" | "struct" | "class" | "type" | "module" | "file" | "bug" | "perf" | "performance" | "optimize" ) }) || query_lower.contains("src/") || query_lower.contains(".rs") || query_lower.contains(".ts") || query_lower.contains(".py"); let docs_intent = tokens.iter().any(|token| { matches!( token.as_str(), "summarize" | "summary" | "roadmap" | "docs" | "document" | "guide" | "readme" ) }); let explicit_paths = extract_explicit_paths(&query_lower); QueryProfile { tokens, code_intent, docs_intent, explicit_paths, } } fn extract_explicit_paths(query_lower: &str) -> Vec<String> { query_lower .split_whitespace() .filter_map(|part| { let trimmed = part.trim_matches(|ch: char| { matches!(ch, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'') }); if trimmed.contains('/') && (trimmed.starts_with("src/") || trimmed.starts_with("docs/") || trimmed.starts_with("crates/") || trimmed.starts_with("scripts/") || trimmed.ends_with(".rs") || trimmed.ends_with(".ts") || trimmed.ends_with(".tsx") || trimmed.ends_with(".py") || trimmed.ends_with(".md") || trimmed.ends_with(".mdx")) { Some(trimmed.to_string()) } else { None } }) .collect() } fn trim_chars(value: &str, limit: usize) -> String { if value.chars().count() <= limit { return value.to_string(); } let keep = limit.saturating_sub(3); value.chars().take(keep).collect::<String>() + "..." } fn session_label(session_id: &str, title: Option<&str>) -> String { if let Some(title) = title && !title.trim().is_empty() { return trim_chars(title.trim(), 80); } let short = session_id.chars().take(8).collect::<String>(); format!("session {}", short) } fn session_summary_body(row: &ai::CodexRecoverRow) -> String { let mut parts = Vec::new(); if let Some(title) = row .title .as_deref() .filter(|value| !value.trim().is_empty()) { parts.push(format!( "Title: {}", trim_chars(title.trim(), MAX_SESSION_TEXT_CHARS) )); } if let Some(branch) = row .git_branch .as_deref() .filter(|value| !value.trim().is_empty()) { parts.push(format!("Branch: {}", branch.trim())); } if let Some(first) = row .first_user_message .as_deref() .and_then(codex_text::sanitize_codex_query_text) { parts.push(format!( "First user message: {}", trim_chars(&first, MAX_SESSION_TEXT_CHARS) )); } if parts.is_empty() { format!("Recent Codex session in {}", row.cwd) } else { parts.join(" | ") } } fn sync_repo_symbol_facts(conn: &Connection, capsule: &repo_capsule::RepoCapsule) -> Result<usize> { if index_is_fresh( conn, &capsule.repo_root, REPO_SYMBOL_INDEX_KIND, REPO_SYMBOL_INDEX_VERSION, capsule.updated_at_unix, )? { return Ok(0); } conn.execute( "DELETE FROM codex_memory_facts WHERE target_path = ?1 AND source_tag = 'repo_symbols'", params![capsule.repo_root.as_str()], )?; let repo_root = Path::new(&capsule.repo_root); let mut changes = 0usize; for fact in collect_repo_symbol_facts(repo_root, capsule)? { changes += upsert_fact( conn, &capsule.repo_root, fact.fact_kind, &fact.title, &fact.body, Some(&fact.path_hint), "repo_symbols", capsule.updated_at_unix, )?; } mark_index_fresh( conn, &capsule.repo_root, REPO_SYMBOL_INDEX_KIND, REPO_SYMBOL_INDEX_VERSION, capsule.updated_at_unix, )?; Ok(changes) } fn sync_repo_session_facts( conn: &Connection, capsule: &repo_capsule::RepoCapsule, ) -> Result<usize> { let repo_root = Path::new(&capsule.repo_root); let recent = ai::read_recent_codex_threads_local(repo_root, false, MAX_SESSION_THREADS, None)?; let source_updated_at = recent .iter() .map(|row| row.updated_at.max(0) as u64) .max() .unwrap_or(0); if index_is_fresh( conn, &capsule.repo_root, REPO_SESSION_INDEX_KIND, REPO_SESSION_INDEX_VERSION, source_updated_at, )? { return Ok(0); } conn.execute( "DELETE FROM codex_memory_facts WHERE target_path = ?1 AND source_tag = 'repo_sessions'", params![capsule.repo_root.as_str()], )?; let mut changes = 0usize; for row in recent { let session_label = session_label(&row.id, row.title.as_deref()); let updated_at_unix = row.updated_at.max(0) as u64; changes += upsert_fact( conn, &capsule.repo_root, "session_recent", &format!("Recent Codex session: {}", session_label), &session_summary_body(&row), None, "repo_sessions", updated_at_unix, )?; if let Some(intent) = row .first_user_message .as_deref() .and_then(codex_text::sanitize_codex_query_text) { changes += upsert_fact( conn, &capsule.repo_root, "session_intent", &format!("Recent Codex intent: {}", session_label), &trim_chars(&intent, MAX_SESSION_TEXT_CHARS), None, "repo_sessions", updated_at_unix, )?; } if let Ok(exchanges) = ai::read_codex_memory_exchanges(&row.id, MAX_SESSION_EXCHANGES_PER_THREAD) { for (index, (user, assistant)) in exchanges.into_iter().enumerate() { let body = format!( "User: {}\nAssistant: {}", trim_chars(&user, MAX_SESSION_TEXT_CHARS), trim_chars(&assistant, MAX_SESSION_TEXT_CHARS) ); changes += upsert_fact( conn, &capsule.repo_root, "session_exchange", &format!("Recent Codex exchange {}: {}", index + 1, session_label), &body, None, "repo_sessions", updated_at_unix, )?; } } } mark_index_fresh( conn, &capsule.repo_root, REPO_SESSION_INDEX_KIND, REPO_SESSION_INDEX_VERSION, source_updated_at, )?; Ok(changes) } fn collect_repo_symbol_facts( repo_root: &Path, capsule: &repo_capsule::RepoCapsule, ) -> Result<Vec<RepoSymbolFact>> { let candidates = collect_symbol_candidate_paths(repo_root, capsule); let mut facts = Vec::new(); for relative_path in candidates { let absolute = repo_root.join(&relative_path); let Ok(metadata) = fs::metadata(&absolute) else { continue; }; if !metadata.is_file() || metadata.len() as usize > MAX_SYMBOL_FILE_BYTES { continue; } if let Some(entrypoint_body) = entrypoint_body_for_path(&relative_path) { facts.push(RepoSymbolFact { fact_kind: "entrypoint", title: format!("Entrypoint: {}", relative_path), body: format!( "{} in {}: {}", entrypoint_body, capsule.repo_id, relative_path ), path_hint: relative_path.clone(), }); } let Ok(content) = fs::read_to_string(&absolute) else { continue; }; facts.extend(extract_symbol_facts(&relative_path, &content)); } Ok(facts) } fn collect_symbol_candidate_paths( repo_root: &Path, capsule: &repo_capsule::RepoCapsule, ) -> Vec<String> { let mut seen = std::collections::BTreeSet::new(); let mut candidates = Vec::new(); for path in &capsule.important_paths { let absolute = repo_root.join(path); if absolute.is_file() && matches_code_extension(&absolute) && seen.insert(path.clone()) { candidates.push(path.clone()); } } let preferred = [ "src/main.rs", "src/lib.rs", "src/mod.rs", "src/index.ts", "src/index.tsx", "src/main.ts", "src/main.tsx", "src/app.ts", "src/app.tsx", "src/App.tsx", "main.py", "app.py", "__init__.py", "index.ts", "index.js", "README.md", "AGENTS.md", "flow.toml", ]; for path in preferred { let absolute = repo_root.join(path); if absolute.is_file() && seen.insert(path.to_string()) { candidates.push(path.to_string()); } } let mut builder = WalkBuilder::new(repo_root); builder .standard_filters(true) .hidden(false) .git_ignore(true) .git_exclude(true) .git_global(true) .max_depth(Some(4)); let mut considered = 0usize; let mut scored = Vec::new(); for entry in builder.build() { let Ok(entry) = entry else { continue; }; if considered >= 600 { break; } let path = entry.path(); if !path.is_file() || !matches_code_extension(path) { continue; } considered += 1; let Some(relative) = path.strip_prefix(repo_root).ok() else { continue; }; let relative_text = relative.display().to_string(); let score = entrypoint_path_score(&relative_text); if score <= 0.0 || seen.contains(&relative_text) { continue; } scored.push((score, relative_text)); } scored.sort_by(|a, b| { b.0.total_cmp(&a.0) .then_with(|| a.1.len().cmp(&b.1.len())) .then_with(|| a.1.cmp(&b.1)) }); for (_, path) in scored { if seen.insert(path.clone()) { candidates.push(path); } if candidates.len() >= MAX_SYMBOL_FILES { break; } } candidates.truncate(MAX_SYMBOL_FILES); candidates } fn entrypoint_path_score(relative_path: &str) -> f64 { let path_lower = relative_path.to_ascii_lowercase(); let file_name = Path::new(relative_path) .file_name() .and_then(|value| value.to_str()) .unwrap_or("") .to_ascii_lowercase(); let mut score = 0.0; if matches!( file_name.as_str(), "main.rs" | "lib.rs" | "mod.rs" | "index.ts" | "index.tsx" | "index.js" | "main.ts" | "main.tsx" | "app.ts" | "app.tsx" | "app.py" | "main.py" | "__init__.py" | "readme.md" | "agents.md" | "flow.toml" ) { score += 6.0; } if path_lower.starts_with("src/") { score += 2.0; } else if path_lower.starts_with("app/") || path_lower.starts_with("lib/") { score += 1.5; } else if path_lower.starts_with("docs/") { score += 1.0; } if path_lower.contains("/cli/") || path_lower.contains("/bin/") { score += 1.0; } score } fn entrypoint_body_for_path(relative_path: &str) -> Option<&'static str> { let path_lower = relative_path.to_ascii_lowercase(); let file_name = Path::new(relative_path) .file_name() .and_then(|value| value.to_str()) .unwrap_or("") .to_ascii_lowercase(); if matches!( file_name.as_str(), "main.rs" | "main.ts" | "main.tsx" | "main.py" | "index.ts" | "index.tsx" | "index.js" ) { return Some("Likely runtime entrypoint"); } if matches!(file_name.as_str(), "lib.rs" | "__init__.py") { return Some("Likely library entrypoint"); } if matches!( file_name.as_str(), "app.ts" | "app.tsx" | "app.py" | "app.js" ) { return Some("Likely application entrypoint"); } if file_name == "flow.toml" { return Some("Flow project entrypoint/config"); } if path_lower.starts_with("docs/") || file_name == "readme.md" || file_name == "agents.md" { return Some("Likely docs/operating guide entrypoint"); } None } fn extract_symbol_facts(relative_path: &str, content: &str) -> Vec<RepoSymbolFact> { let extension = Path::new(relative_path) .extension() .and_then(|value| value.to_str()) .unwrap_or(""); let mut facts = match extension { "rs" => extract_rust_symbol_facts(relative_path, content), "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => { extract_ts_symbol_facts(relative_path, content) } "py" => extract_python_symbol_facts(relative_path, content), "go" => extract_go_symbol_facts(relative_path, content), "md" | "mdx" => extract_markdown_heading_facts(relative_path, content), _ => Vec::new(), }; facts.truncate(MAX_SYMBOLS_PER_FILE); facts } fn extract_rust_symbol_facts(relative_path: &str, content: &str) -> Vec<RepoSymbolFact> { let mut facts = Vec::new(); for line in content.lines() { let trimmed = line.trim(); let (kind, name) = if let Some(name) = parse_prefixed_name(trimmed, &["pub fn ", "fn "]) { ("fn", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["pub struct ", "struct ", "pub(crate) struct "]) { ("struct", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["pub enum ", "enum "]) { ("enum", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["pub trait ", "trait "]) { ("trait", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["pub mod ", "mod "]) { ("mod", name) } else { continue; }; facts.push(symbol_fact(relative_path, kind, &name)); if facts.len() >= MAX_SYMBOLS_PER_FILE { break; } } facts } fn extract_ts_symbol_facts(relative_path: &str, content: &str) -> Vec<RepoSymbolFact> { let mut facts = Vec::new(); for line in content.lines() { let trimmed = line.trim(); let (kind, name) = if let Some(name) = parse_prefixed_name( trimmed, &[ "export async function ", "export function ", "async function ", "function ", ], ) { ("function", name) } else if let Some(name) = parse_prefixed_name( trimmed, &["export class ", "class ", "export default class "], ) { ("class", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["export interface ", "interface "]) { ("interface", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["export type ", "type "]) { ("type", name) } else if let Some(name) = parse_const_name(trimmed) { ("const", name) } else { continue; }; facts.push(symbol_fact(relative_path, kind, &name)); if facts.len() >= MAX_SYMBOLS_PER_FILE { break; } } facts } fn extract_python_symbol_facts(relative_path: &str, content: &str) -> Vec<RepoSymbolFact> { let mut facts = Vec::new(); for line in content.lines() { let trimmed = line.trim(); let (kind, name) = if let Some(name) = parse_prefixed_name(trimmed, &["def ", "async def "]) { ("function", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["class "]) { ("class", name) } else { continue; }; facts.push(symbol_fact(relative_path, kind, &name)); if facts.len() >= MAX_SYMBOLS_PER_FILE { break; } } facts } fn extract_go_symbol_facts(relative_path: &str, content: &str) -> Vec<RepoSymbolFact> { let mut facts = Vec::new(); for line in content.lines() { let trimmed = line.trim(); let (kind, name) = if let Some(name) = parse_go_func_name(trimmed) { ("func", name) } else if let Some(name) = parse_prefixed_name(trimmed, &["type "]) { ("type", name) } else { continue; }; facts.push(symbol_fact(relative_path, kind, &name)); if facts.len() >= MAX_SYMBOLS_PER_FILE { break; } } facts } fn extract_markdown_heading_facts(relative_path: &str, content: &str) -> Vec<RepoSymbolFact> { let mut facts = Vec::new(); for line in content.lines() { let trimmed = line.trim(); if !trimmed.starts_with('#') { continue; } let heading = trimmed.trim_start_matches('#').trim(); if heading.len() < 3 { continue; } facts.push(RepoSymbolFact { fact_kind: "doc_heading", title: format!("Doc heading: {}", heading), body: format!("Heading in {}: {}", relative_path, heading), path_hint: relative_path.to_string(), }); if facts.len() >= 4 { break; } } facts } fn symbol_fact(relative_path: &str, kind: &str, name: &str) -> RepoSymbolFact { RepoSymbolFact { fact_kind: "symbol", title: format!("Symbol: {}", name), body: format!("{} {} in {}", kind, name, relative_path), path_hint: relative_path.to_string(), } } fn parse_prefixed_name(trimmed: &str, prefixes: &[&str]) -> Option<String> { for prefix in prefixes { let Some(rest) = trimmed.strip_prefix(prefix) else { continue; }; let name: String = rest .chars() .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-') .collect(); if !name.is_empty() { return Some(name); } } None } fn parse_const_name(trimmed: &str) -> Option<String> { let rest = if let Some(value) = trimmed.strip_prefix("export const ") { value } else if let Some(value) = trimmed.strip_prefix("const ") { value } else { return None; }; let name: String = rest .chars() .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '$') .collect(); if name.is_empty() { None } else { Some(name) } } fn parse_go_func_name(trimmed: &str) -> Option<String> { let rest = trimmed.strip_prefix("func ")?; let rest = if rest.starts_with('(') { let idx = rest.find(')')?; rest.get(idx + 1..)?.trim_start() } else { rest }; let name: String = rest .chars() .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') .collect(); if name.is_empty() { None } else { Some(name) } } fn index_is_fresh( conn: &Connection, target_path: &str, index_kind: &str, version: u32, source_updated_at_unix: u64, ) -> Result<bool> { let row = conn.query_row( "SELECT version, source_updated_at_unix FROM codex_memory_indexes \ WHERE target_path = ?1 AND index_kind = ?2", params![target_path, index_kind], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), ); match row { Ok((stored_version, stored_source_updated)) => Ok(stored_version == version as i64 && stored_source_updated == source_updated_at_unix as i64), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), Err(err) => Err(err.into()), } } fn mark_index_fresh( conn: &Connection, target_path: &str, index_kind: &str, version: u32, source_updated_at_unix: u64, ) -> Result<()> { let updated_at_unix = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(source_updated_at_unix); conn.execute( "INSERT INTO codex_memory_indexes ( target_path, index_kind, version, source_updated_at_unix, updated_at_unix ) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(target_path, index_kind) DO UPDATE SET version = excluded.version, source_updated_at_unix = excluded.source_updated_at_unix, updated_at_unix = excluded.updated_at_unix", params![ target_path, index_kind, version as i64, source_updated_at_unix as i64, updated_at_unix as i64, ], )?; Ok(()) } fn search_code_paths( repo_root: &Path, profile: &QueryProfile, limit: usize, ) -> Vec<CodexMemoryCodeHit> { if (profile.tokens.is_empty() && profile.explicit_paths.is_empty()) || limit == 0 || !repo_root.exists() { return Vec::new(); } let mut builder = WalkBuilder::new(repo_root); builder .standard_filters(true) .hidden(false) .git_ignore(true) .git_exclude(true) .git_global(true) .max_depth(Some(8)); let mut considered = 0usize; let mut hits = Vec::new(); for entry in builder.build() { let Ok(entry) = entry else { continue; }; if considered >= 2000 { break; } let path = entry.path(); if !path.is_file() || !matches_code_extension(path) { continue; } considered += 1; let Some(relative) = path.strip_prefix(repo_root).ok() else { continue; }; let relative_text = relative.display().to_string(); let score = score_code_path(&relative_text, profile); if score <= 0.0 { continue; } hits.push(CodexMemoryCodeHit { path: relative_text, score, }); } hits.sort_by(|a, b| { b.score .total_cmp(&a.score) .then_with(|| a.path.len().cmp(&b.path.len())) .then_with(|| a.path.cmp(&b.path)) }); hits.truncate(limit); hits } fn search_symbols_for_code_paths( repo_root: &Path, code_paths: &[CodexMemoryCodeHit], profile: &QueryProfile, limit: usize, ) -> Vec<CodexMemoryFactHit> { let mut hits = Vec::new(); for code_path in code_paths.iter().take(limit.min(4)) { let extension = Path::new(&code_path.path) .extension() .and_then(|value| value.to_str()) .unwrap_or(""); if matches!(extension, "md" | "mdx") { continue; } let absolute = repo_root.join(&code_path.path); let Ok(metadata) = fs::metadata(&absolute) else { continue; }; if !metadata.is_file() || metadata.len() as usize > MAX_SYMBOL_FILE_BYTES { continue; } let Ok(content) = fs::read_to_string(&absolute) else { continue; }; for fact in extract_symbol_facts(&code_path.path, &content) { let score = fact_score( profile, fact.fact_kind, &fact.title, &fact.body, Some(&fact.path_hint), ) + (code_path.score * 0.6); if score <= 0.0 { continue; } hits.push(CodexMemoryFactHit { fact_kind: fact.fact_kind.to_string(), title: fact.title, body: fact.body, path_hint: Some(fact.path_hint), source_tag: "live_symbol".to_string(), score, }); } } hits.sort_by(|a, b| b.score.total_cmp(&a.score)); hits.truncate(limit); hits } fn merge_dynamic_symbol_hits( hits: &mut Vec<CodexMemoryFactHit>, dynamic_symbols: Vec<CodexMemoryFactHit>, ) { let mut seen = std::collections::BTreeSet::new(); for hit in hits.iter() { seen.insert(( hit.fact_kind.clone(), hit.title.clone(), hit.path_hint.clone().unwrap_or_default(), )); } for hit in dynamic_symbols { let key = ( hit.fact_kind.clone(), hit.title.clone(), hit.path_hint.clone().unwrap_or_default(), ); if seen.insert(key) { hits.push(hit); } } } fn extract_symbol_snippets( repo_root: &Path, facts: &[CodexMemoryFactHit], limit: usize, ) -> Vec<CodexMemorySnippetHit> { let mut snippets = Vec::new(); let mut seen = std::collections::BTreeSet::new(); for fact in facts { if snippets.len() >= limit { break; } if fact.fact_kind != "symbol" { continue; } let Some(path) = fact.path_hint.as_deref() else { continue; }; if !seen.insert(path.to_string()) { continue; } let Some(symbol_name) = fact.title.strip_prefix("Symbol: ").map(str::trim) else { continue; }; let absolute = repo_root.join(path); let Ok(metadata) = fs::metadata(&absolute) else { continue; }; if !metadata.is_file() || metadata.len() as usize > MAX_SYMBOL_FILE_BYTES { continue; } let Ok(content) = fs::read_to_string(&absolute) else { continue; }; let Some(snippet) = find_symbol_snippet(&content, symbol_name) else { continue; }; snippets.push(CodexMemorySnippetHit { path: path.to_string(), symbol: symbol_name.to_string(), snippet, score: fact.score, }); } snippets } fn find_symbol_snippet(content: &str, symbol_name: &str) -> Option<String> { let lines: Vec<&str> = content.lines().collect(); let symbol_lower = symbol_name.to_ascii_lowercase(); let start_idx = lines.iter().position(|line| { let trimmed = line.trim(); let lower = trimmed.to_ascii_lowercase(); lower.contains(&symbol_lower) && (trimmed.starts_with("pub ") || trimmed.starts_with("fn ") || trimmed.starts_with("struct ") || trimmed.starts_with("enum ") || trimmed.starts_with("trait ") || trimmed.starts_with("class ") || trimmed.starts_with("interface ") || trimmed.starts_with("type ") || trimmed.starts_with("export ") || trimmed.starts_with("async ") || trimmed.starts_with("def ") || trimmed.starts_with("func ")) })?; let mut excerpt = Vec::new(); for line in lines.iter().skip(start_idx).take(MAX_SNIPPET_LINES) { let trimmed = line.trim_end(); if trimmed.is_empty() && !excerpt.is_empty() { break; } if !trimmed.is_empty() { excerpt.push(trimmed.trim().to_string()); } } if excerpt.is_empty() { return None; } Some(trim_chars(&excerpt.join(" | "), MAX_SNIPPET_CHARS)) } fn matches_code_extension(path: &Path) -> bool { let Some(name) = path.file_name().and_then(|value| value.to_str()) else { return false; }; if matches!(name, "README.md" | "README.mdx" | "AGENTS.md" | "flow.toml") { return true; } let Some(extension) = path.extension().and_then(|value| value.to_str()) else { return false; }; matches!( extension, "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "py" | "go" | "md" | "mdx" | "toml" | "json" | "jsonc" | "yaml" | "yml" | "moon" | "cpp" | "cc" | "c" | "h" | "hpp" | "java" | "kt" | "swift" ) } fn score_code_path(relative_path: &str, profile: &QueryProfile) -> f64 { let path_lower = relative_path.to_ascii_lowercase(); let file_name = Path::new(relative_path) .file_name() .and_then(|value| value.to_str()) .unwrap_or("") .to_ascii_lowercase(); let mut score = 0.0; for token in &profile.tokens { if file_name == *token || file_name.starts_with(&format!("{token}.")) { score += 6.0; continue; } if file_name.contains(token) { score += 4.0; } if path_lower.contains(&format!("/{token}/")) { score += 3.0; } else if path_lower.contains(token) { score += 2.0; } } for explicit_path in &profile.explicit_paths { if path_lower == *explicit_path { score += 14.0; } else if path_lower.contains(explicit_path) { score += 8.0; } } if profile.code_intent { if path_lower.starts_with("src/") || path_lower.contains("/src/") { score += 2.0; } else if path_lower.starts_with("crates/") || path_lower.starts_with("app/") { score += 1.0; } else if path_lower.starts_with("docs/") { score -= 1.0; } } else if profile.docs_intent { if path_lower.starts_with("docs/") || path_lower.ends_with(".md") || path_lower.ends_with(".mdx") { score += 2.0; } else if path_lower.starts_with("src/") { score -= 0.5; } } else if path_lower.starts_with("src/") { score += 0.5; } else if path_lower.starts_with("docs/") { score += 0.3; } score } fn render_query_result( repo_root: &str, query: &str, profile: &QueryProfile, facts: &[CodexMemoryFactHit], code_paths: &[CodexMemoryCodeHit], snippets: &[CodexMemorySnippetHit], ) -> String { let mut lines = vec![format!("- Memory repo root: {}", repo_root)]; lines.push(format!("- Memory query: {}", query.trim())); for fact in select_render_fact_hits(facts, profile, 6) { let mut line = format!("- {}: {}", fact.fact_kind, fact.body); if let Some(path) = fact.path_hint.as_deref() { line.push_str(&format!(" ({})", path)); } lines.push(line); } for snippet in snippets { lines.push(format!( "- snippet {}::{} => {}", snippet.path, snippet.symbol, snippet.snippet )); } for hit in code_paths { lines.push(format!("- code_path: {}", hit.path)); } lines.join("\n") } fn select_render_fact_hits<'a>( facts: &'a [CodexMemoryFactHit], profile: &QueryProfile, limit: usize, ) -> Vec<&'a CodexMemoryFactHit> { let mut selected = Vec::new(); let mut seen = std::collections::BTreeSet::new(); let preferred_kinds: &[&str] = if profile.code_intent { &[ "symbol", "entrypoint", "session_exchange", "session_intent", "important_path", "command", ] } else if profile.docs_intent { &["doc_heading", "docs_hint", "summary", "important_path"] } else { &[ "session_intent", "session_exchange", "symbol", "entrypoint", "command", "important_path", ] }; for preferred_kind in preferred_kinds { for fact in facts { if fact.fact_kind != *preferred_kind { continue; } let key = ( fact.fact_kind.as_str(), fact.title.as_str(), fact.path_hint.as_deref().unwrap_or(""), ); if seen.insert(key) { selected.push(fact); break; } } } for fact in facts { if selected.len() >= limit { break; } if profile.code_intent && matches!( fact.fact_kind.as_str(), "doc_heading" | "docs_hint" | "summary" ) && selected .iter() .filter(|item| item.fact_kind == "doc_heading") .count() >= 1 { continue; } if matches!(fact.fact_kind.as_str(), "doc_heading" | "docs_hint") && selected .iter() .filter(|item| item.fact_kind == "doc_heading") .count() >= 2 { continue; } let key = ( fact.fact_kind.as_str(), fact.title.as_str(), fact.path_hint.as_deref().unwrap_or(""), ); if seen.insert(key) { selected.push(fact); } } selected.truncate(limit); selected } fn map_recent_entry(row: &rusqlite::Row<'_>) -> Result<CodexMemoryRecentEntry, rusqlite::Error> { let recorded_at_unix: i64 = row.get(1)?; Ok(CodexMemoryRecentEntry { event_kind: row.get(0)?, recorded_at_unix: recorded_at_unix.max(0) as u64, target_path: row.get(2)?, session_id: row.get(3)?, route: row.get(4)?, query: row.get(5)?, success: row.get(6)?, }) } #[cfg(test)] mod tests { use super::{ CodexMemoryFactHit, CodexMemoryStats, QueryProfile, REPO_SYMBOL_INDEX_KIND, REPO_SYMBOL_INDEX_VERSION, build_query_profile, entrypoint_body_for_path, event_key, extract_symbol_facts, extract_symbol_snippets, fact_score, find_symbol_snippet, index_is_fresh, insert_marshaled, map_recent_entry, mark_index_fresh, matches_code_extension, open_connection_at, score_code_path, search_code_paths, select_render_fact_hits, session_summary_body, upsert_fact, }; use rusqlite::params; use std::fs; use std::path::Path; use tempfile::tempdir; #[test] fn event_key_changes_with_kind_and_payload() { let a = event_key("skill_eval_event", "{\"a\":1}"); let b = event_key("skill_eval_event", "{\"a\":2}"); let c = event_key("skill_eval_outcome", "{\"a\":1}"); assert_ne!(a, b); assert_ne!(a, c); } #[test] fn inserts_are_deduped_and_recent_rows_roundtrip() { let temp = tempdir().expect("tempdir"); let db_path = temp.path().join("memory.sqlite"); let conn = open_connection_at(&db_path).expect("open db"); let inserted = insert_marshaled( &conn, "skill_eval_event", 42, Some("/tmp/repo"), Some("session-1"), Some("runtime-1"), Some("new-with-context"), Some("write plan"), None, "{\"route\":\"new-with-context\"}", ) .expect("insert event"); assert!(inserted); let inserted_again = insert_marshaled( &conn, "skill_eval_event", 42, Some("/tmp/repo"), Some("session-1"), Some("runtime-1"), Some("new-with-context"), Some("write plan"), None, "{\"route\":\"new-with-context\"}", ) .expect("dedupe event"); assert!(!inserted_again); let mut stmt = conn .prepare( "SELECT event_kind, recorded_at_unix, target_path, session_id, route, query, success \ FROM codex_memory_events", ) .expect("prepare select"); let row = stmt .query_row(params![], map_recent_entry) .expect("query first row"); assert_eq!(row.event_kind, "skill_eval_event"); assert_eq!(row.recorded_at_unix, 42); assert_eq!(row.target_path.as_deref(), Some("/tmp/repo")); assert_eq!(row.route.as_deref(), Some("new-with-context")); } #[test] fn stats_query_counts_rows() { let temp = tempdir().expect("tempdir"); let db_path = temp.path().join("memory.sqlite"); let conn = open_connection_at(&db_path).expect("open db"); insert_marshaled( &conn, "skill_eval_event", 100, Some("/tmp/repo"), None, None, Some("route"), Some("query"), None, "{\"kind\":\"event\"}", ) .expect("insert event"); insert_marshaled( &conn, "skill_eval_outcome", 101, Some("/tmp/repo"), Some("session-1"), None, None, None, Some(1.0), "{\"kind\":\"outcome\"}", ) .expect("insert outcome"); let mut stmt = conn .prepare( "SELECT COUNT(*), \ 0, \ COALESCE(SUM(CASE WHEN event_kind = 'skill_eval_event' THEN 1 ELSE 0 END), 0), \ COALESCE(SUM(CASE WHEN event_kind = 'skill_eval_outcome' THEN 1 ELSE 0 END), 0), \ MAX(recorded_at_unix) \ FROM codex_memory_events", ) .expect("prepare stats"); let stats: CodexMemoryStats = stmt .query_row([], |row| { Ok(CodexMemoryStats { root_dir: String::new(), db_path: String::new(), total_events: row.get::<_, i64>(0)? as usize, total_facts: row.get::<_, i64>(1)? as usize, skill_eval_events: row.get::<_, i64>(2)? as usize, skill_eval_outcomes: row.get::<_, i64>(3)? as usize, latest_recorded_at_unix: row.get::<_, Option<i64>>(4)?.map(|v| v as u64), }) }) .expect("read stats"); assert_eq!(stats.total_events, 2); assert_eq!(stats.total_facts, 0); assert_eq!(stats.skill_eval_events, 1); assert_eq!(stats.skill_eval_outcomes, 1); assert_eq!(stats.latest_recorded_at_unix, Some(101)); } #[test] fn fact_score_prefers_title_and_paths() { let profile = build_query_profile("reload speed build123d keyboard"); let score = fact_score( &profile, "important_path", "Important path: projects/keyboard/keyboard.py", "Key file or directory in gumyr/build123d: projects/keyboard/keyboard.py", Some("projects/keyboard/keyboard.py"), ); assert!(score > 5.0); } #[test] fn upsert_fact_replaces_existing_row() { let temp = tempdir().expect("tempdir"); let db_path = temp.path().join("memory.sqlite"); let conn = open_connection_at(&db_path).expect("open db"); upsert_fact( &conn, "/tmp/repo", "summary", "Summary", "first body", None, "repo_capsule", 10, ) .expect("insert fact"); upsert_fact( &conn, "/tmp/repo", "summary", "Summary", "first body", None, "repo_capsule", 20, ) .expect("update fact"); let updated_at: i64 = conn .query_row( "SELECT updated_at_unix FROM codex_memory_facts WHERE target_path = '/tmp/repo'", [], |row| row.get(0), ) .expect("select fact"); assert_eq!(updated_at, 20); } #[test] fn code_path_search_prefers_matching_files() { let temp = tempdir().expect("tempdir"); let root = temp.path().join("repo"); fs::create_dir_all(root.join("src")).expect("create src"); fs::create_dir_all(root.join("docs")).expect("create docs"); fs::write(root.join("src/codex_runtime.rs"), "// runtime\n").expect("write runtime"); fs::write(root.join("src/ai.rs"), "// ai\n").expect("write ai"); fs::write(root.join("docs/runtime-skills.md"), "# Runtime skills\n").expect("write docs"); let profile = build_query_profile("codex runtime skills"); let hits = search_code_paths(&root, &profile, 3); assert!(!hits.is_empty()); assert!(hits.iter().any(|hit| hit.path == "src/codex_runtime.rs")); } #[test] fn code_extension_filter_accepts_repo_docs_and_code() { assert!(matches_code_extension(Path::new("README.md"))); assert!(matches_code_extension(Path::new("src/main.rs"))); assert!(matches_code_extension(Path::new("docs/guide.mdx"))); assert!(!matches_code_extension(Path::new("target/debug/f"))); } #[test] fn code_path_scoring_prefers_exact_filename_hits() { let profile = QueryProfile { tokens: vec!["codex_runtime".to_string()], code_intent: true, docs_intent: false, explicit_paths: Vec::new(), }; let exact = score_code_path("src/codex_runtime.rs", &profile); let loose = score_code_path("src/runtime.rs", &profile); assert!(exact > loose); } #[test] fn symbol_extraction_finds_rust_and_ts_entrypoints() { let rust = extract_symbol_facts( "src/codex_memory.rs", "pub fn query_repo_facts() {}\nstruct RepoMemory {}\nmod helpers {}\n", ); assert!( rust.iter() .any(|fact| fact.title == "Symbol: query_repo_facts") ); assert!(rust.iter().any(|fact| fact.title == "Symbol: RepoMemory")); let ts = extract_symbol_facts( "src/index.ts", "export function startFlow() {}\nexport class CodexBridge {}\nexport const runtimeSkill = 1;\n", ); assert!(ts.iter().any(|fact| fact.title == "Symbol: startFlow")); assert!(ts.iter().any(|fact| fact.title == "Symbol: CodexBridge")); assert_eq!( entrypoint_body_for_path("src/index.ts"), Some("Likely runtime entrypoint") ); } #[test] fn symbol_index_freshness_roundtrips() { let temp = tempdir().expect("tempdir"); let db_path = temp.path().join("memory.sqlite"); let conn = open_connection_at(&db_path).expect("open db"); assert!( !index_is_fresh( &conn, "/tmp/repo", REPO_SYMBOL_INDEX_KIND, REPO_SYMBOL_INDEX_VERSION, 42, ) .expect("initial freshness") ); mark_index_fresh( &conn, "/tmp/repo", REPO_SYMBOL_INDEX_KIND, REPO_SYMBOL_INDEX_VERSION, 42, ) .expect("mark fresh"); assert!( index_is_fresh( &conn, "/tmp/repo", REPO_SYMBOL_INDEX_KIND, REPO_SYMBOL_INDEX_VERSION, 42, ) .expect("fresh after mark") ); assert!( !index_is_fresh( &conn, "/tmp/repo", REPO_SYMBOL_INDEX_KIND, REPO_SYMBOL_INDEX_VERSION, 43, ) .expect("stale after source change") ); } #[test] fn render_selection_prefers_symbols_before_doc_noise() { let facts = vec![ CodexMemoryFactHit { fact_kind: "doc_heading".to_string(), title: "Doc heading: Skills".to_string(), body: "Heading in docs/skills.md: Skills".to_string(), path_hint: Some("docs/skills.md".to_string()), source_tag: "repo_symbols".to_string(), score: 10.0, }, CodexMemoryFactHit { fact_kind: "symbol".to_string(), title: "Symbol: query_repo_facts".to_string(), body: "fn query_repo_facts in src/codex_memory.rs".to_string(), path_hint: Some("src/codex_memory.rs".to_string()), source_tag: "live_symbol".to_string(), score: 8.0, }, CodexMemoryFactHit { fact_kind: "entrypoint".to_string(), title: "Entrypoint: src/main.rs".to_string(), body: "Likely runtime entrypoint in repo: src/main.rs".to_string(), path_hint: Some("src/main.rs".to_string()), source_tag: "repo_symbols".to_string(), score: 7.0, }, ]; let profile = QueryProfile { tokens: vec!["implement".to_string()], code_intent: true, docs_intent: false, explicit_paths: Vec::new(), }; let selected = select_render_fact_hits(&facts, &profile, 3); assert_eq!(selected[0].fact_kind, "symbol"); assert_eq!(selected[1].fact_kind, "entrypoint"); } #[test] fn query_profile_detects_code_intent_and_explicit_path() { let profile = build_query_profile("implement codex runtime skill ranking in src/ai.rs"); assert!(profile.code_intent); assert!(!profile.docs_intent); assert!( profile .explicit_paths .iter() .any(|value| value == "src/ai.rs") ); } #[test] fn query_profile_detects_docs_intent() { let profile = build_query_profile("summarize codex control plane roadmap"); assert!(profile.docs_intent); } #[test] fn session_summary_body_strips_contextual_first_prompt_noise() { let row = crate::ai::CodexRecoverRow { id: "019ce6ce-c77a-7d52-838e-c01f8820f6b8".to_string(), updated_at: 42, cwd: "/tmp/repo".to_string(), title: Some("Session title".to_string()), first_user_message: Some( "# AGENTS.md instructions for /tmp\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>\n<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>\nwrite plan for rollout" .to_string(), ), git_branch: Some("main".to_string()), model: None, reasoning_effort: None, }; let body = session_summary_body(&row); assert!(body.contains("write plan for rollout")); assert!(!body.contains("AGENTS.md")); assert!(!body.contains("<environment_context>")); } #[test] fn snippet_extraction_returns_compact_symbol_excerpt() { let content = "pub struct CodexRuntimeSkill {\n pub name: String,\n pub path: String,\n}\n"; let snippet = find_symbol_snippet(content, "CodexRuntimeSkill").expect("snippet"); assert!(snippet.contains("pub struct CodexRuntimeSkill")); assert!(snippet.contains("pub name: String") || snippet.contains("pub name: String,")); } #[test] fn extract_symbol_snippets_picks_top_symbol_hits() { let temp = tempdir().expect("tempdir"); let root = temp.path().join("repo"); fs::create_dir_all(root.join("src")).expect("create src"); fs::write( root.join("src/codex_runtime.rs"), "pub struct CodexRuntimeSkill {\n pub name: String,\n}\n", ) .expect("write runtime"); let hits = vec![CodexMemoryFactHit { fact_kind: "symbol".to_string(), title: "Symbol: CodexRuntimeSkill".to_string(), body: "struct CodexRuntimeSkill in src/codex_runtime.rs".to_string(), path_hint: Some("src/codex_runtime.rs".to_string()), source_tag: "live_symbol".to_string(), score: 9.0, }]; let snippets = extract_symbol_snippets(&root, &hits, 2); assert_eq!(snippets.len(), 1); assert_eq!(snippets[0].path, "src/codex_runtime.rs"); assert!(snippets[0].snippet.contains("CodexRuntimeSkill")); } #[test] fn render_selection_prefers_session_context_for_general_queries() { let facts = vec![ CodexMemoryFactHit { fact_kind: "symbol".to_string(), title: "Symbol: CodexRuntimeSkill".to_string(), body: "struct CodexRuntimeSkill in src/codex_runtime.rs".to_string(), path_hint: Some("src/codex_runtime.rs".to_string()), source_tag: "repo_symbols".to_string(), score: 8.0, }, CodexMemoryFactHit { fact_kind: "session_intent".to_string(), title: "Recent Codex intent: runtime work".to_string(), body: "implement codex runtime skill ranking".to_string(), path_hint: None, source_tag: "repo_sessions".to_string(), score: 9.0, }, CodexMemoryFactHit { fact_kind: "session_exchange".to_string(), title: "Recent Codex exchange 1: runtime work".to_string(), body: "User: implement codex runtime skill ranking\nAssistant: focus on ai.rs" .to_string(), path_hint: None, source_tag: "repo_sessions".to_string(), score: 8.5, }, ]; let profile = QueryProfile { tokens: vec!["runtime".to_string()], code_intent: false, docs_intent: false, explicit_paths: Vec::new(), }; let selected = select_render_fact_hits(&facts, &profile, 3); assert_eq!(selected[0].fact_kind, "session_intent"); assert_eq!(selected[1].fact_kind, "session_exchange"); } } ================================================ FILE: src/codex_runtime.rs ================================================ use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{activity_log, codex_skill_eval, config}; const RUNTIME_VERSION: u32 = 1; const RUNTIME_PREFIX: &str = "flow-runtime-"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexRuntimeSkill { pub name: String, pub kind: String, pub path: String, pub trigger: String, #[serde(default)] pub source: Option<String>, #[serde(default)] pub original_name: Option<String>, #[serde(default)] pub estimated_chars: Option<usize>, #[serde(default)] pub match_reason: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexRuntimeState { pub version: u32, pub token: String, pub created_at_unix: u64, pub target_path: String, pub query: String, pub skills: Vec<CodexRuntimeSkill>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CodexRuntimeActivation { pub state_path: PathBuf, pub skills: Vec<CodexRuntimeSkill>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexExternalSkill { pub source_name: String, pub name: String, pub path: String, pub description: String, pub estimated_chars: usize, pub category: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexSkillSourceSnapshot { pub name: String, pub path: String, pub enabled: bool, pub skill_count: usize, pub skills: Vec<CodexExternalSkill>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexInstalledSkillSnapshot { pub name: String, pub path: String, pub description: String, pub runtime_managed: bool, pub category: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexSkillCatalogEntry { pub name: String, pub description: String, pub category: String, pub path: String, pub sources: Vec<String>, pub installed: bool, pub runtime_managed: bool, #[serde(default)] pub estimated_chars: Option<usize>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexRuntimeStateSnapshot { pub token: String, pub created_at_unix: u64, pub target_path: String, pub query: String, pub skills: Vec<String>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexSkillsDashboardSnapshot { pub target_path: String, pub sources: Vec<CodexSkillSourceSnapshot>, pub installed_skills: Vec<CodexInstalledSkillSnapshot>, pub catalog: Vec<CodexSkillCatalogEntry>, pub recent_runtime_states: Vec<CodexRuntimeStateSnapshot>, pub runtime_states_for_target: usize, } #[derive(Debug, Clone)] struct RuntimeSkillCandidate { score: f64, skill: CodexRuntimeSkill, source_dir: Option<PathBuf>, } impl CodexRuntimeActivation { fn prompt_skill_names(&self) -> Vec<String> { self.skills .iter() .map(|skill| { skill .original_name .as_deref() .unwrap_or(skill.name.as_str()) .to_string() }) .collect() } pub fn inject_into_prompt(&self, prompt: &str) -> String { let names = self.prompt_skill_names(); if names.is_empty() { return prompt.trim().to_string(); } format!( "[Active Flow skills: {}]\n\n{}", names.join(", "), prompt.trim() ) } } fn runtime_root() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()? .join("codex") .join("runtime")) } fn runtime_roots() -> Vec<PathBuf> { config::global_state_dir_candidates() .into_iter() .map(|root| root.join("codex").join("runtime")) .collect() } fn runtime_states_dir() -> Result<PathBuf> { let dir = runtime_root()?.join("states"); fs::create_dir_all(&dir)?; Ok(dir) } fn runtime_skills_dir() -> Result<PathBuf> { let dir = runtime_root()?.join("skills"); fs::create_dir_all(&dir)?; Ok(dir) } fn agents_skill_root() -> PathBuf { env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".agents/skills") } fn codex_global_skill_root() -> PathBuf { env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".codex/skills") } fn slugify(value: &str) -> String { let mut out = String::new(); let mut last_dash = false; for ch in value.chars() { let mapped = if ch.is_ascii_alphanumeric() { Some(ch.to_ascii_lowercase()) } else { Some('-') }; if let Some(mapped) = mapped { if mapped == '-' { if !out.is_empty() && !last_dash { out.push('-'); last_dash = true; } } else { out.push(mapped); last_dash = false; } } } out.trim_matches('-').to_string() } fn parse_frontmatter_field(content: &str, field: &str) -> Option<String> { let after_start = content.strip_prefix("---\n")?; let end = after_start.find("\n---")?; let frontmatter = &after_start[..end]; for line in frontmatter.lines() { let trimmed = line.trim(); let prefix = format!("{field}:"); if let Some(value) = trimmed.strip_prefix(&prefix) { return Some( value .trim() .trim_matches('"') .trim_matches('\'') .to_string(), ); } } None } fn default_skill_sources() -> Vec<config::CodexSkillSourceConfig> { let vercel_path = config::expand_path("~/repos/vercel-labs/skills"); if looks_like_skill_source_root(&vercel_path) { return vec![config::CodexSkillSourceConfig { name: "vercel-labs-skills".to_string(), path: "~/repos/vercel-labs/skills".to_string(), enabled: Some(true), }]; } Vec::new() } fn configured_skill_sources( codex_cfg: &config::CodexConfig, ) -> Vec<config::CodexSkillSourceConfig> { let mut sources = if codex_cfg.skill_sources.is_empty() { default_skill_sources() } else { codex_cfg.skill_sources.clone() }; sources.retain(|source| source.enabled.unwrap_or(true)); sources } fn looks_like_skill_source_root(root: &Path) -> bool { collect_skill_dirs(root) .map(|dirs| !dirs.is_empty()) .unwrap_or(false) } fn collect_skill_dirs(root: &Path) -> Result<Vec<PathBuf>> { let mut dirs = BTreeSet::new(); let nested_root = root.join("skills"); for base in [nested_root.as_path(), root] { if !base.is_dir() { continue; } for entry in fs::read_dir(base)? { let entry = entry?; let skill_dir = entry.path(); if !skill_dir.is_dir() { continue; } if skill_dir.join("SKILL.md").is_file() { dirs.insert(skill_dir); } } } Ok(dirs.into_iter().collect()) } fn discover_source_skills( source: &config::CodexSkillSourceConfig, ) -> Result<Vec<CodexExternalSkill>> { let root = config::expand_path(&source.path); let skill_dirs = collect_skill_dirs(&root)?; let mut skills = Vec::new(); for skill_dir in skill_dirs { let skill_file = skill_dir.join("SKILL.md"); let raw = fs::read_to_string(&skill_file) .with_context(|| format!("failed to read {}", skill_file.display()))?; let name = parse_frontmatter_field(&raw, "name") .filter(|value| !value.is_empty()) .unwrap_or_else(|| { skill_dir .file_name() .map(|value| value.to_string_lossy().to_string()) .unwrap_or_else(|| "skill".to_string()) }); let description = parse_frontmatter_field(&raw, "description").unwrap_or_default(); let category = classify_skill_category(&name, &description).to_string(); skills.push(CodexExternalSkill { source_name: source.name.clone(), name, path: skill_dir.display().to_string(), description, estimated_chars: raw.chars().count(), category, }); } skills.sort_by(|a, b| a.name.cmp(&b.name)); Ok(skills) } fn tokenize_keywords(value: &str) -> Vec<String> { value .split(|ch: char| !ch.is_ascii_alphanumeric()) .filter(|part| !part.is_empty()) .map(|part| part.to_ascii_lowercase()) .filter(|part| { part.len() >= 4 && !matches!( part.as_str(), "skill" | "skills" | "with" | "from" | "that" | "this" | "used" | "when" | "help" | "helps" | "agent" | "agents" | "their" | "into" | "your" ) }) .collect() } fn contains_any(haystack: &str, needles: &[&str]) -> bool { needles.iter().any(|needle| haystack.contains(needle)) } fn classify_skill_category(name: &str, description: &str) -> &'static str { let normalized = format!("{name} {description}").to_ascii_lowercase(); if contains_any( &normalized, &[ "review", "lint", "style", "testing-practice", "test-practice", "code quality", "adversarial", ], ) { return "quality"; } if contains_any( &normalized, &[ "verify", "verification", "playwright", "driver", "assert", "smoke", "e2e", "tmux", "checkout", "signup", ], ) { return "verification"; } if contains_any( &normalized, &[ "grafana", "dashboard", "query", "cohort", "analysis", "trace", "funnel", "retention", "log", "metric", ], ) { return "analysis"; } if contains_any( &normalized, &[ "scaffold", "template", "migration", "boilerplate", "create-app", "new-", ], ) { return "scaffold"; } if contains_any( &normalized, &[ "deploy", "release", "rollback", "ci/cd", "cicd", "prod", "cherry-pick", "merge", ], ) { return "delivery"; } if contains_any( &normalized, &[ "runbook", "debug", "oncall", "alert", "incident", "correlat", "investigation", ], ) { return "runbook"; } if contains_any( &normalized, &[ "orphan", "cleanup", "kubectl", "volume", "pod", "infra", "cost", "dependency-management", ], ) { return "ops"; } if contains_any( &normalized, &[ "workflow", "ticket", "standup", "recap", "automation", "process", "slack", ], ) { return "workflow"; } "reference" } fn match_external_skill(query: &str, skill: &CodexExternalSkill) -> f64 { let normalized_query = query.to_ascii_lowercase(); let skill_phrase = tokenize_keywords(&skill.name).join(" "); if !skill_phrase.is_empty() && normalized_query.contains(&skill_phrase) { return 1.0; } let mut terms = tokenize_keywords(&skill.name); terms.extend(tokenize_keywords(&skill.description)); terms.sort(); terms.dedup(); if terms.is_empty() { return 0.0; } let hits = terms .iter() .filter(|term| normalized_query.contains(term.as_str())) .count(); hits as f64 / terms.len().min(6) as f64 } fn describe_external_skill_match(query: &str, skill: &CodexExternalSkill) -> Option<String> { let normalized_query = query.to_ascii_lowercase(); let skill_phrase = tokenize_keywords(&skill.name).join(" "); if !skill_phrase.is_empty() && normalized_query.contains(&skill_phrase) { return Some(format!("matched skill name phrase `{skill_phrase}`")); } let mut terms = tokenize_keywords(&skill.name); terms.extend(tokenize_keywords(&skill.description)); terms.sort(); terms.dedup(); let hits = terms .into_iter() .filter(|term| normalized_query.contains(term.as_str())) .collect::<Vec<_>>(); if hits.is_empty() { return None; } let preview = hits.into_iter().take(4).collect::<Vec<_>>().join(", "); Some(format!("matched query terms: {preview}")) } fn copy_dir_recursive(source: &Path, dest: &Path) -> Result<()> { fs::create_dir_all(dest)?; for entry in fs::read_dir(source)? { let entry = entry?; let source_path = entry.path(); let dest_path = dest.join(entry.file_name()); let metadata = fs::symlink_metadata(&source_path)?; if metadata.is_dir() { copy_dir_recursive(&source_path, &dest_path)?; } else if metadata.file_type().is_symlink() { let target = fs::read_link(&source_path)?; #[cfg(unix)] std::os::unix::fs::symlink(target, &dest_path)?; #[cfg(windows)] { if metadata.is_dir() { std::os::windows::fs::symlink_dir(target, &dest_path)?; } else { std::os::windows::fs::symlink_file(target, &dest_path)?; } } } else { fs::copy(&source_path, &dest_path)?; } } Ok(()) } fn rewrite_skill_name(content: &str, name: &str) -> String { if let Some(after_start) = content.strip_prefix("---\n") { if let Some(end) = after_start.find("\n---") { let mut lines = after_start[..end] .lines() .map(|line| { if line.trim_start().starts_with("name:") { format!("name: {name}") } else { line.to_string() } }) .collect::<Vec<_>>(); if !lines .iter() .any(|line| line.trim_start().starts_with("name:")) { lines.insert(0, format!("name: {name}")); } return format!("---\n{}\n---{}", lines.join("\n"), &after_start[end..]); } } format!("---\nname: {name}\n---\n\n{content}") } fn allocate_plan_path(root: &Path, stem: &str) -> PathBuf { let candidate = root.join(format!("{stem}.md")); if !candidate.exists() { return candidate; } let mut index = 2usize; loop { let next = root.join(format!("{stem}-{index}.md")); if !next.exists() { return next; } index += 1; } } fn derive_plan_title(body: &str) -> String { for raw_line in body.lines() { let line = raw_line.trim(); if line.is_empty() { continue; } if let Some(rest) = line.strip_prefix('#') { let cleaned = rest.trim().trim_start_matches('#').trim(); if !cleaned.is_empty() { return cleaned.to_string(); } } return line.to_string(); } "Plan".to_string() } fn append_session_footer(body: &str, session_id: Option<&str>) -> String { let trimmed = body.trim_end(); let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else { return trimmed.to_string(); }; let footer = format!("Made from {} Codex session.", session_id); if trimmed.ends_with(&footer) { return trimmed.to_string(); } format!("{trimmed}\n\n{footer}") } fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0) } fn runtime_token(target_path: &Path, query: &str) -> String { let mut hasher = Sha256::new(); hasher.update(target_path.to_string_lossy().as_bytes()); hasher.update(b"\n"); hasher.update(query.as_bytes()); hasher.update(b"\n"); hasher.update(std::process::id().to_string().as_bytes()); hasher.update(b"\n"); hasher.update(unix_now().to_string().as_bytes()); let digest = format!("{:x}", hasher.finalize()); digest[..12.min(digest.len())].to_string() } fn plan_skill_name(token: &str) -> String { format!("{RUNTIME_PREFIX}plan-{token}") } fn build_plan_skill_markdown(skill_name: &str) -> String { format!( r#"--- name: {skill_name} description: Write the finished markdown plan for this task into `~/plan` using `f codex runtime write-plan`. Use only for the current task. policy: allow_implicit_invocation: false --- # Flow Runtime Plan Writer Use this only when the user asks to write, save, or document a plan. ## Command Write the plan with: ```bash cat <<'EOF' | f codex runtime write-plan --title "<short title>" <markdown plan body> EOF ``` The command prints the absolute path after writing. ## Hard rules - write the finished plan to `~/plan` - keep the chat response short - end with the absolute path on its own line - do not leave the plan only in chat when the user explicitly asked to write it "# ) } fn looks_like_plan_request(query: &str) -> bool { let normalized = query.to_ascii_lowercase(); [ "write plan", "save this plan", "save the plan", "document the plan", "put the plan in ~/plan", "write this up as a plan", ] .iter() .any(|needle| normalized.contains(needle)) } pub fn discover_external_skills( _target_path: &Path, codex_cfg: &config::CodexConfig, ) -> Result<Vec<CodexExternalSkill>> { let mut out = Vec::new(); for source in configured_skill_sources(codex_cfg) { out.extend(discover_source_skills(&source)?); } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) } pub fn dashboard_snapshot( target_path: &Path, codex_cfg: &config::CodexConfig, recent_limit: usize, ) -> Result<CodexSkillsDashboardSnapshot> { let target_display = target_path.display().to_string(); let mut sources = Vec::new(); for source in configured_skill_sources(codex_cfg) { let skills = discover_source_skills(&source)?; sources.push(CodexSkillSourceSnapshot { name: source.name, path: config::expand_path(&source.path).display().to_string(), enabled: source.enabled.unwrap_or(true), skill_count: skills.len(), skills, }); } sources.sort_by(|a, b| a.name.cmp(&b.name)); let installed_skills = discover_installed_skills()?; let catalog = build_skill_catalog(&sources, &installed_skills); let runtime_states = load_runtime_states()?; let runtime_states_for_target = runtime_states .iter() .filter(|state| state.target_path == target_display) .count(); let recent_runtime_states = runtime_states .into_iter() .take(recent_limit) .map(|state| CodexRuntimeStateSnapshot { token: state.token, created_at_unix: state.created_at_unix, target_path: state.target_path, query: state.query, skills: state .skills .into_iter() .map(|skill| skill.original_name.unwrap_or(skill.name)) .collect(), }) .collect(); Ok(CodexSkillsDashboardSnapshot { target_path: target_display, sources, installed_skills, catalog, recent_runtime_states, runtime_states_for_target, }) } fn discover_installed_skills() -> Result<Vec<CodexInstalledSkillSnapshot>> { let root = codex_global_skill_root(); if !root.is_dir() { return Ok(Vec::new()); } let mut installed = Vec::new(); for entry in fs::read_dir(&root)? { let entry = entry?; let skill_dir = entry.path(); if !skill_dir.is_dir() { continue; } let skill_file = skill_dir.join("SKILL.md"); if !skill_file.is_file() { continue; } let raw = fs::read_to_string(&skill_file) .with_context(|| format!("failed to read {}", skill_file.display()))?; let name = parse_frontmatter_field(&raw, "name") .filter(|value| !value.is_empty()) .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()); let description = parse_frontmatter_field(&raw, "description").unwrap_or_default(); let category = classify_skill_category(&name, &description).to_string(); installed.push(CodexInstalledSkillSnapshot { runtime_managed: name.starts_with(RUNTIME_PREFIX), name, path: skill_dir.display().to_string(), description: description.clone(), category, }); } installed.sort_by(|a, b| a.name.cmp(&b.name)); Ok(installed) } fn build_skill_catalog( sources: &[CodexSkillSourceSnapshot], installed_skills: &[CodexInstalledSkillSnapshot], ) -> Vec<CodexSkillCatalogEntry> { let mut merged = BTreeMap::<String, CodexSkillCatalogEntry>::new(); for source in sources { for skill in &source.skills { let key = skill.name.to_ascii_lowercase(); let entry = merged.entry(key).or_insert_with(|| CodexSkillCatalogEntry { name: skill.name.clone(), description: skill.description.clone(), category: skill.category.clone(), path: skill.path.clone(), sources: Vec::new(), installed: false, runtime_managed: false, estimated_chars: Some(skill.estimated_chars), }); if entry.description.is_empty() && !skill.description.is_empty() { entry.description = skill.description.clone(); } if entry.path.is_empty() { entry.path = skill.path.clone(); } if entry.category == "reference" && skill.category != "reference" { entry.category = skill.category.clone(); } if !entry.sources.iter().any(|value| value == &source.name) { entry.sources.push(source.name.clone()); } entry.estimated_chars = entry.estimated_chars.or(Some(skill.estimated_chars)); } } for skill in installed_skills { let key = skill.name.to_ascii_lowercase(); let entry = merged.entry(key).or_insert_with(|| CodexSkillCatalogEntry { name: skill.name.clone(), description: skill.description.clone(), category: skill.category.clone(), path: skill.path.clone(), sources: vec!["global".to_string()], installed: true, runtime_managed: skill.runtime_managed, estimated_chars: None, }); entry.installed = true; entry.runtime_managed |= skill.runtime_managed; if entry.description.is_empty() && !skill.description.is_empty() { entry.description = skill.description.clone(); } if entry.path.is_empty() { entry.path = skill.path.clone(); } if entry.category == "reference" && skill.category != "reference" { entry.category = skill.category.clone(); } if !entry.sources.iter().any(|value| value == "global") { entry.sources.push("global".to_string()); } } let mut catalog = merged.into_values().collect::<Vec<_>>(); for entry in &mut catalog { entry.sources.sort(); entry.sources.dedup(); entry.category = classify_skill_category(&entry.name, &entry.description).to_string(); } catalog.sort_by(|a, b| a.name.cmp(&b.name)); catalog } pub fn format_external_skills(skills: &[CodexExternalSkill]) -> String { if skills.is_empty() { return "No external Codex skill sources discovered.".to_string(); } let mut lines = vec!["# codex skill-source".to_string()]; for skill in skills { lines.push(format!( "- {} [{}] {} chars", skill.name, skill.source_name, skill.estimated_chars )); if !skill.description.is_empty() { lines.push(format!(" {}", skill.description)); } } lines.join("\n") } pub fn sync_external_skills( target_path: &Path, codex_cfg: &config::CodexConfig, selected_skills: &[String], force: bool, ) -> Result<usize> { let discovered = discover_external_skills(target_path, codex_cfg)?; let selected = selected_skills .iter() .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) .collect::<Vec<_>>(); let root = codex_global_skill_root(); fs::create_dir_all(&root)?; let mut installed = 0usize; for skill in discovered { if !selected.is_empty() && !selected .iter() .any(|value| value == &skill.name.to_ascii_lowercase()) { continue; } let dest = root.join(&skill.name); if dest.exists() { if !force { continue; } fs::remove_dir_all(&dest) .with_context(|| format!("failed to replace {}", dest.display()))?; } copy_dir_recursive(Path::new(&skill.path), &dest)?; installed += 1; } Ok(installed) } pub fn prepare_runtime_activation( target_path: &Path, query: &str, enabled: bool, codex_cfg: &config::CodexConfig, ) -> Result<Option<CodexRuntimeActivation>> { if !enabled { return Ok(None); } let token = runtime_token(target_path, query); let state_path = runtime_states_dir()?.join(format!("{token}.json")); let skills_root = runtime_skills_dir()?.join(&token); fs::create_dir_all(&skills_root)?; let scorecard = codex_skill_eval::load_scorecard(target_path)?; let mut candidates = Vec::new(); if looks_like_plan_request(query) { let skill_name = plan_skill_name(&token); let skill_dir = skills_root.join(&skill_name); let markdown = build_plan_skill_markdown(&skill_name); fs::create_dir_all(&skill_dir)?; fs::write(skill_dir.join("SKILL.md"), &markdown)?; let scorecard_score = scorecard .as_ref() .and_then(|value| { value .skills .iter() .find(|skill| skill.name == "plan_write") .map(|skill| skill.score) }) .unwrap_or(0.0); let score = 2.5 + scorecard_score / 100.0 - markdown.chars().count() as f64 / 5000.0; candidates.push(RuntimeSkillCandidate { score, skill: CodexRuntimeSkill { name: skill_name, kind: "plan_write".to_string(), path: skill_dir.display().to_string(), trigger: "write plan".to_string(), source: Some("flow".to_string()), original_name: Some("plan_write".to_string()), estimated_chars: Some(markdown.chars().count()), match_reason: Some("query explicitly asked to write or save a plan".to_string()), }, source_dir: None, }); } for external in discover_external_skills(target_path, codex_cfg)? { let match_score = match_external_skill(query, &external); if match_score < 0.55 { continue; } let scorecard_score = scorecard .as_ref() .and_then(|value| { value .skills .iter() .find(|skill| skill.name == external.name) .map(|skill| skill.score) }) .unwrap_or(0.0); let runtime_name = format!( "{RUNTIME_PREFIX}ext-{}-{}-{}", slugify(&external.source_name), slugify(&external.name), token ); let score = match_score * 2.0 + scorecard_score / 100.0 - external.estimated_chars as f64 / 6000.0; candidates.push(RuntimeSkillCandidate { score, skill: CodexRuntimeSkill { name: runtime_name, kind: "external".to_string(), path: skills_root .join(format!( "{}-{}", slugify(&external.source_name), slugify(&external.name) )) .display() .to_string(), trigger: external.name.clone(), source: Some(external.source_name.clone()), original_name: Some(external.name.clone()), estimated_chars: Some(external.estimated_chars), match_reason: describe_external_skill_match(query, &external), }, source_dir: Some(PathBuf::from(&external.path)), }); } if candidates.is_empty() { return Ok(None); } candidates.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); let mut total_chars = 0usize; let mut selected = Vec::new(); for candidate in candidates { let estimated = candidate.skill.estimated_chars.unwrap_or(0); if !selected.is_empty() && total_chars + estimated > 8000 { continue; } total_chars += estimated; selected.push(candidate); if selected.len() >= 2 { break; } } let mut skills = Vec::new(); for candidate in selected { if let Some(source_dir) = candidate.source_dir.as_ref() { let materialized_dir = skills_root.join(format!( "{}-{}", slugify(candidate.skill.source.as_deref().unwrap_or("external")), slugify( candidate .skill .original_name .as_deref() .unwrap_or(candidate.skill.name.as_str()) ) )); copy_dir_recursive(source_dir, &materialized_dir)?; let skill_file = materialized_dir.join("SKILL.md"); let raw = fs::read_to_string(&skill_file) .with_context(|| format!("failed to read {}", skill_file.display()))?; fs::write(&skill_file, rewrite_skill_name(&raw, &candidate.skill.name)) .with_context(|| format!("failed to rewrite {}", skill_file.display()))?; let mut skill = candidate.skill.clone(); skill.path = materialized_dir.display().to_string(); skills.push(skill); } else { skills.push(candidate.skill); } } let state = CodexRuntimeState { version: RUNTIME_VERSION, token, created_at_unix: unix_now(), target_path: target_path.display().to_string(), query: query.to_string(), skills: skills.clone(), }; fs::write(&state_path, serde_json::to_vec_pretty(&state)?)?; Ok(Some(CodexRuntimeActivation { state_path, skills })) } pub fn load_runtime_states() -> Result<Vec<CodexRuntimeState>> { let mut states = Vec::new(); for dir in runtime_roots().into_iter().map(|root| root.join("states")) { if !dir.exists() { continue; } for entry in fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|value| value.to_str()) != Some("json") { continue; } let Ok(raw) = fs::read(&path) else { continue; }; let Ok(state) = serde_json::from_slice::<CodexRuntimeState>(&raw) else { continue; }; states.push(state); } } states.sort_by(|a, b| b.created_at_unix.cmp(&a.created_at_unix)); states.dedup_by(|a, b| a.token == b.token); Ok(states) } pub fn clear_runtime_states() -> Result<usize> { let mut removed = 0usize; for root in runtime_roots() { let states_dir = root.join("states"); if states_dir.exists() { for entry in fs::read_dir(&states_dir)? { let entry = entry?; let path = entry.path(); if path.is_file() { fs::remove_file(&path)?; removed += 1; } } } let skills_dir = root.join("skills"); if skills_dir.exists() { fs::remove_dir_all(&skills_dir)?; } } let user_root = agents_skill_root(); if user_root.exists() { for entry in fs::read_dir(&user_root)? { let entry = entry?; let path = entry.path(); let Some(name) = path.file_name().and_then(|value| value.to_str()) else { continue; }; if !name.starts_with(RUNTIME_PREFIX) { continue; } let meta = fs::symlink_metadata(&path)?; if meta.file_type().is_symlink() || meta.is_file() { fs::remove_file(&path)?; } else if meta.is_dir() { fs::remove_dir_all(&path)?; } } } Ok(removed) } pub fn format_runtime_states(states: &[CodexRuntimeState]) -> String { if states.is_empty() { return "No Flow-managed Codex runtime skills.".to_string(); } let mut lines = vec!["# codex runtime".to_string()]; for state in states { lines.push(format!("- token: {}", state.token)); lines.push(format!(" target: {}", state.target_path)); lines.push(format!(" query: {}", state.query)); lines.push(format!( " skills: {}", state .skills .iter() .map(|skill| { skill .original_name .as_deref() .unwrap_or(skill.name.as_str()) }) .collect::<Vec<_>>() .join(", ") )); } lines.join("\n") } fn load_runtime_state_from_env() -> Option<CodexRuntimeState> { let raw_path = env::var("FLOW_CODEX_RUNTIME_STATE_PATH") .ok() .or_else(|| env::var("FLOW_CODEX_RUNTIME_STATE").ok())?; let path = PathBuf::from(raw_path); let raw = fs::read(path).ok()?; serde_json::from_slice::<CodexRuntimeState>(&raw).ok() } pub fn write_plan_from_stdin( title: Option<&str>, stem: Option<&str>, dir: Option<&str>, source_session: Option<&str>, ) -> Result<PathBuf> { let mut body = String::new(); io::stdin() .read_to_string(&mut body) .context("failed to read plan body from stdin")?; if body.trim().is_empty() { bail!("plan body is empty"); } let root = dir .map(PathBuf::from) .or_else(|| { env::var_os("HOME") .map(PathBuf::from) .map(|home| home.join("plan")) }) .unwrap_or_else(|| PathBuf::from("./plan")); fs::create_dir_all(&root)?; let resolved_title = title .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| derive_plan_title(&body)); let mut resolved_stem = stem .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| slugify(&resolved_title)); if !resolved_stem.ends_with("-plan") { resolved_stem.push_str("-plan"); } let session = source_session .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| { env::var("CODEX_THREAD_ID") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) }); let path = allocate_plan_path(&root, &resolved_stem); let final_body = append_session_footer(&body, session.as_deref()); fs::write(&path, final_body + "\n")?; if let Some(runtime_state) = load_runtime_state_from_env() { let _ = codex_skill_eval::log_outcome(&codex_skill_eval::CodexSkillOutcomeEvent { version: 1, recorded_at_unix: unix_now(), runtime_token: Some(runtime_state.token.clone()), session_id: session.clone(), target_path: Some(runtime_state.target_path.clone()), kind: "plan_written".to_string(), skill_names: runtime_state .skills .iter() .map(|skill| { skill .original_name .clone() .unwrap_or_else(|| skill.name.clone()) }) .collect(), artifact_path: Some(path.display().to_string()), success: 1.0, trace_id: None, span_id: None, parent_span_id: None, service_name: None, }); let mut activity_event = activity_log::ActivityEvent::done("plan.write", resolved_title); activity_event.runtime_token = Some(runtime_state.token.clone()); activity_event.target_path = Some(runtime_state.target_path.clone()); activity_event.artifact_path = Some(path.display().to_string()); activity_event.source = Some("codex-runtime".to_string()); let _ = activity_log::append_daily_event(activity_event); } Ok(path) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn plan_request_detection_stays_specific() { assert!(looks_like_plan_request("write plan")); assert!(looks_like_plan_request("Please document the plan")); assert!(!looks_like_plan_request("document this feature")); assert!(!looks_like_plan_request("planning support cleanup")); } #[test] fn runtime_prompt_prelude_is_human_readable() { let activation = CodexRuntimeActivation { state_path: PathBuf::from("/tmp/runtime.json"), skills: vec![CodexRuntimeSkill { name: "flow-runtime-plan-abc".to_string(), kind: "plan_write".to_string(), path: "/tmp/skill".to_string(), trigger: "write plan".to_string(), source: Some("flow".to_string()), original_name: Some("plan_write".to_string()), estimated_chars: Some(120), match_reason: Some("query explicitly asked to write or save a plan".to_string()), }], }; assert_eq!( activation.inject_into_prompt("write plan"), "[Active Flow skills: plan_write]\n\nwrite plan" ); } #[test] fn session_footer_is_added_once() { let once = append_session_footer("# Plan", Some("019c")); let twice = append_session_footer(&once, Some("019c")); assert_eq!(once, twice); assert!(once.ends_with("Made from 019c Codex session.")); } #[test] fn discover_external_skills_supports_nested_repo_layout() { let temp = tempdir().expect("tempdir"); let source_root = temp.path().join("vercel-skills"); let skill_dir = source_root.join("skills").join("find-skills"); fs::create_dir_all(&skill_dir).expect("create nested skill dir"); fs::write( skill_dir.join("SKILL.md"), "---\nname: find-skills\ndescription: Find repo skills.\n---\n", ) .expect("write skill"); let cfg = config::CodexConfig { skill_sources: vec![config::CodexSkillSourceConfig { name: "nested".to_string(), path: source_root.display().to_string(), enabled: Some(true), }], ..Default::default() }; let skills = discover_external_skills(temp.path(), &cfg).expect("discover nested skills"); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "find-skills"); assert_eq!(skills[0].source_name, "nested"); assert_eq!(skills[0].category, "reference"); } #[test] fn discover_external_skills_supports_flat_repo_layout() { let temp = tempdir().expect("tempdir"); let source_root = temp.path().join("dimillian-skills"); let skill_dir = source_root.join("react-component-performance"); fs::create_dir_all(&skill_dir).expect("create flat skill dir"); fs::write( skill_dir.join("SKILL.md"), "---\nname: react-component-performance\ndescription: Optimize React renders.\n---\n", ) .expect("write skill"); let cfg = config::CodexConfig { skill_sources: vec![config::CodexSkillSourceConfig { name: "flat".to_string(), path: source_root.display().to_string(), enabled: Some(true), }], ..Default::default() }; let skills = discover_external_skills(temp.path(), &cfg).expect("discover flat skills"); assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "react-component-performance"); assert_eq!(skills[0].source_name, "flat"); assert_eq!(skills[0].category, "reference"); } #[test] fn classify_skill_category_prefers_verification_for_driver_skills() { assert_eq!( classify_skill_category( "signup-flow-driver", "Runs signup verification in a headless browser" ), "verification" ); } #[test] fn describe_external_skill_match_reports_name_phrase_hits() { let skill = CodexExternalSkill { source_name: "vercel".to_string(), name: "github".to_string(), path: "/tmp/vercel/github".to_string(), description: "Interact with GitHub from the CLI".to_string(), estimated_chars: 120, category: "workflow".to_string(), }; assert_eq!( describe_external_skill_match( "check https://github.com/fl2024008/prometheus/pull/2922", &skill ) .as_deref(), Some("matched skill name phrase `github`") ); } #[test] fn build_skill_catalog_merges_source_and_global_install() { let sources = vec![CodexSkillSourceSnapshot { name: "vercel".to_string(), path: "/tmp/vercel".to_string(), enabled: true, skill_count: 1, skills: vec![CodexExternalSkill { source_name: "vercel".to_string(), name: "github".to_string(), path: "/tmp/vercel/github".to_string(), description: "Interact with GitHub from the CLI".to_string(), estimated_chars: 120, category: "workflow".to_string(), }], }]; let installed = vec![CodexInstalledSkillSnapshot { name: "github".to_string(), path: "/tmp/global/github".to_string(), description: "Interact with GitHub from the CLI".to_string(), runtime_managed: false, category: "workflow".to_string(), }]; let catalog = build_skill_catalog(&sources, &installed); assert_eq!(catalog.len(), 1); assert_eq!(catalog[0].name, "github"); assert!(catalog[0].installed); assert_eq!( catalog[0].sources, vec!["global".to_string(), "vercel".to_string()] ); } } ================================================ FILE: src/codex_skill_eval.rs ================================================ use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{codex_memory, codex_text, config}; const SKILL_EVAL_VERSION: u32 = 1; const SKILL_EVAL_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexSkillEvalEvent { pub version: u32, pub recorded_at_unix: u64, pub mode: String, pub action: String, pub route: String, pub target_path: String, pub launch_path: String, pub query: String, #[serde(default)] pub session_id: Option<String>, pub runtime_token: Option<String>, pub runtime_skills: Vec<String>, pub prompt_context_budget_chars: usize, pub prompt_chars: usize, pub injected_context_chars: usize, pub reference_count: usize, #[serde(default)] pub trace_id: Option<String>, #[serde(default)] pub span_id: Option<String>, #[serde(default)] pub parent_span_id: Option<String>, #[serde(default)] pub workflow_kind: Option<String>, #[serde(default)] pub service_name: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexSkillScore { pub name: String, pub sample_size: usize, pub outcome_samples: usize, pub pass_rate: f64, pub avg_affinity: f64, pub baseline_affinity: f64, pub normalized_gain: f64, pub avg_context_chars: f64, pub score: f64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexSkillOutcomeEvent { pub version: u32, pub recorded_at_unix: u64, pub runtime_token: Option<String>, #[serde(default)] pub session_id: Option<String>, pub target_path: Option<String>, pub kind: String, pub skill_names: Vec<String>, pub artifact_path: Option<String>, pub success: f64, #[serde(default)] pub trace_id: Option<String>, #[serde(default)] pub span_id: Option<String>, #[serde(default)] pub parent_span_id: Option<String>, #[serde(default)] pub service_name: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CodexSkillScorecard { pub version: u32, pub generated_at_unix: u64, pub target_path: String, pub samples: usize, pub skills: Vec<CodexSkillScore>, } #[derive(Default)] struct SkillAggregate { count: usize, outcome_count: usize, success_sum: f64, total_affinity_used: f64, total_affinity_all: f64, total_context_chars: usize, } fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0) } fn skill_eval_root() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()? .join("codex") .join("skill-eval")) } fn skill_eval_roots() -> Vec<PathBuf> { config::global_state_dir_candidates() .into_iter() .map(|root| root.join("codex").join("skill-eval")) .collect() } fn load_events_from_paths( paths: Vec<PathBuf>, target_path: Option<&Path>, limit: usize, ) -> Result<Vec<CodexSkillEvalEvent>> { let mut events = Vec::new(); for path in paths { if !path.exists() { continue; } let path_ref = path.as_path(); let _ = visit_lines_reverse(path_ref, limit, |line| { let trimmed = line.trim(); if trimmed.is_empty() { return None::<CodexSkillEvalEvent>; } let mut event = serde_json::from_str::<CodexSkillEvalEvent>(trimmed).ok()?; event.query = codex_text::sanitize_codex_query_text(&event.query)?; if let Some(filter) = target_path && !path_matches(&event, filter) { return None; } Some(event) })? .map(|mut loaded| events.append(&mut loaded)); } events.sort_by(|a, b| b.recorded_at_unix.cmp(&a.recorded_at_unix)); if events.len() > limit { events.truncate(limit); } Ok(events) } fn load_outcomes_from_paths( paths: Vec<PathBuf>, target_path: Option<&Path>, limit: usize, ) -> Result<Vec<CodexSkillOutcomeEvent>> { let mut outcomes = Vec::new(); for path in paths { if !path.exists() { continue; } let path_ref = path.as_path(); let _ = visit_lines_reverse(path_ref, limit, |line| { let trimmed = line.trim(); if trimmed.is_empty() { return None::<CodexSkillOutcomeEvent>; } let outcome = serde_json::from_str::<CodexSkillOutcomeEvent>(trimmed).ok()?; if let Some(filter) = target_path { let Some(target) = outcome.target_path.as_deref() else { return None; }; let filter = filter.display().to_string(); if target != filter && !target.starts_with(&(filter + "/")) { return None; } } Some(outcome) })? .map(|mut loaded| outcomes.append(&mut loaded)); } outcomes.sort_by(|a, b| b.recorded_at_unix.cmp(&a.recorded_at_unix)); if outcomes.len() > limit { outcomes.truncate(limit); } Ok(outcomes) } fn events_path() -> Result<PathBuf> { let root = skill_eval_root()?; fs::create_dir_all(&root)?; Ok(root.join("events.jsonl")) } pub fn events_log_path() -> Result<PathBuf> { events_path() } fn outcomes_path() -> Result<PathBuf> { let root = skill_eval_root()?; fs::create_dir_all(&root)?; Ok(root.join("outcomes.jsonl")) } pub fn outcomes_log_path() -> Result<PathBuf> { outcomes_path() } fn scorecards_dir() -> Result<PathBuf> { let dir = skill_eval_root()?.join("scorecards"); fs::create_dir_all(&dir)?; Ok(dir) } fn scorecard_key(target_path: &Path) -> String { let mut hasher = Sha256::new(); hasher.update(target_path.display().to_string().as_bytes()); let digest = format!("{:x}", hasher.finalize()); digest[..12.min(digest.len())].to_string() } fn scorecard_path(target_path: &Path) -> Result<PathBuf> { Ok(scorecards_dir()?.join(format!("{}.json", scorecard_key(target_path)))) } fn tokenize_words(value: &str) -> Vec<String> { value .split(|ch: char| !ch.is_ascii_alphanumeric()) .filter(|part| !part.is_empty()) .map(|part| part.to_ascii_lowercase()) .filter(|part| { part.len() >= 4 && !matches!( part.as_str(), "flow" | "runtime" | "skill" | "skills" | "this" | "that" | "with" | "from" | "into" | "write" | "using" | "codex" | "session" | "query" | "prompt" | "plan" ) }) .collect() } fn affinity_for_skill(skill_name: &str, query: &str) -> f64 { let query_lower = query.to_ascii_lowercase(); let skill_words = tokenize_words(skill_name); if skill_words.is_empty() { return 0.0; } let phrase = skill_words.join(" "); if !phrase.is_empty() && query_lower.contains(&phrase) { return 1.0; } let hits = skill_words .iter() .filter(|word| query_lower.contains(word.as_str())) .count(); hits as f64 / skill_words.len() as f64 } fn calculate_normalized_gain(p_with: f64, p_without: f64) -> f64 { if p_without >= 1.0 { return if p_with >= 1.0 { 0.0 } else { -1.0 }; } (p_with - p_without) / (1.0 - p_without) } fn path_matches(event: &CodexSkillEvalEvent, target_path: &Path) -> bool { let target = target_path.display().to_string(); event.target_path == target || event.launch_path == target || event.target_path.starts_with(&(target.clone() + "/")) || event.launch_path.starts_with(&(target + "/")) } pub fn log_event(event: &CodexSkillEvalEvent) -> Result<()> { let mut sanitized = event.clone(); let Some(query) = codex_text::sanitize_codex_query_text(&sanitized.query) else { return Ok(()); }; sanitized.query = query; let path = events_path()?; let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("failed to open {}", path.display()))?; serde_json::to_writer(&mut file, &sanitized) .context("failed to encode codex skill-eval event")?; file.write_all(b"\n") .context("failed to terminate codex skill-eval event")?; let _ = codex_memory::mirror_skill_eval_event(&sanitized); Ok(()) } pub fn log_outcome(outcome: &CodexSkillOutcomeEvent) -> Result<()> { let path = outcomes_path()?; let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("failed to open {}", path.display()))?; serde_json::to_writer(&mut file, outcome) .context("failed to encode codex skill-eval outcome")?; file.write_all(b"\n") .context("failed to terminate codex skill-eval outcome")?; let _ = codex_memory::mirror_skill_outcome_event(outcome); Ok(()) } pub fn event_count() -> usize { load_events(None, usize::MAX) .map(|events| events.len()) .unwrap_or(0) } pub fn outcome_count() -> usize { load_outcomes(None, usize::MAX) .map(|outcomes| outcomes.len()) .unwrap_or(0) } pub fn load_events(target_path: Option<&Path>, limit: usize) -> Result<Vec<CodexSkillEvalEvent>> { load_events_from_paths( skill_eval_roots() .into_iter() .map(|root| root.join("events.jsonl")) .collect(), target_path, limit, ) } fn collect_recent_targets( events: Vec<CodexSkillEvalEvent>, max_targets: usize, within_hours: u64, ) -> Vec<PathBuf> { let mut seen = std::collections::BTreeSet::new(); let mut out = Vec::new(); let cutoff = unix_now().saturating_sub(within_hours.saturating_mul(3600)); for event in events { if event.recorded_at_unix < cutoff { continue; } if event.target_path.trim().is_empty() { continue; } let path = PathBuf::from(&event.target_path); if !path.exists() { continue; } if seen.insert(event.target_path.clone()) { out.push(path); if out.len() >= max_targets { break; } } } out } pub fn recent_targets(limit: usize, max_targets: usize, within_hours: u64) -> Result<Vec<PathBuf>> { Ok(collect_recent_targets( load_events(None, limit)?, max_targets, within_hours, )) } pub fn load_outcomes( target_path: Option<&Path>, limit: usize, ) -> Result<Vec<CodexSkillOutcomeEvent>> { load_outcomes_from_paths( skill_eval_roots() .into_iter() .map(|root| root.join("outcomes.jsonl")) .collect(), target_path, limit, ) } pub fn rebuild_scorecard(target_path: &Path, limit: usize) -> Result<CodexSkillScorecard> { let events = load_events(Some(target_path), limit)?; let outcomes = load_outcomes(Some(target_path), limit)?; let scorecard = build_scorecard(target_path, events, outcomes); let path = scorecard_path(target_path)?; fs::write(&path, serde_json::to_vec_pretty(&scorecard)?) .with_context(|| format!("failed to write {}", path.display()))?; Ok(scorecard) } fn build_scorecard( target_path: &Path, events: Vec<CodexSkillEvalEvent>, outcomes: Vec<CodexSkillOutcomeEvent>, ) -> CodexSkillScorecard { let outcomes_by_token = outcomes .iter() .filter_map(|outcome| { outcome .runtime_token .as_deref() .map(|token| (token.to_string(), outcome)) }) .fold( HashMap::<String, Vec<&CodexSkillOutcomeEvent>>::new(), |mut acc, (token, outcome)| { acc.entry(token).or_default().push(outcome); acc }, ); let outcomes_by_session = outcomes .iter() .filter_map(|outcome| { outcome .session_id .as_deref() .map(|session_id| (session_id.to_string(), outcome)) }) .fold( HashMap::<String, Vec<&CodexSkillOutcomeEvent>>::new(), |mut acc, (session_id, outcome)| { acc.entry(session_id).or_default().push(outcome); acc }, ); let known_skills = events .iter() .flat_map(|event| event.runtime_skills.iter().cloned()) .collect::<std::collections::BTreeSet<_>>(); let mut aggregates = known_skills .iter() .map(|name| (name.clone(), SkillAggregate::default())) .collect::<HashMap<_, _>>(); for event in &events { let query = event.query.trim(); if query.is_empty() { continue; } let used = event .runtime_skills .iter() .cloned() .collect::<std::collections::HashSet<_>>(); for skill_name in &known_skills { let affinity = affinity_for_skill(skill_name, query); let entry = aggregates.entry(skill_name.clone()).or_default(); entry.total_affinity_all += affinity; if used.contains(skill_name) { entry.count += 1; entry.total_affinity_used += affinity; entry.total_context_chars += event.injected_context_chars; let matched = event .runtime_token .as_deref() .and_then(|token| outcomes_by_token.get(token)) .or_else(|| { event .session_id .as_deref() .and_then(|session_id| outcomes_by_session.get(session_id)) }); if let Some(matched) = matched { let best_success = matched .iter() .filter(|outcome| { outcome.skill_names.is_empty() || outcome.skill_names.iter().any(|name| name == skill_name) }) .map(|outcome| outcome.success) .fold(0.0f64, f64::max); entry.outcome_count += 1; entry.success_sum += best_success; } } } } let total_events = events.len().max(1) as f64; let baseline_pass_rate = { let mut success = 0.0f64; let mut samples = 0usize; for event in &events { let matched = event .runtime_token .as_deref() .and_then(|token| outcomes_by_token.get(token)) .or_else(|| { event .session_id .as_deref() .and_then(|session_id| outcomes_by_session.get(session_id)) }); let Some(matched) = matched else { continue; }; let best = matched .iter() .map(|outcome| outcome.success) .fold(0.0, f64::max); success += best; samples += 1; } if samples == 0 { 0.0 } else { success / samples as f64 } }; let mut skills = aggregates .into_iter() .filter_map(|(name, agg)| { if agg.count == 0 { return None; } let avg_affinity = agg.total_affinity_used / agg.count as f64; let baseline_affinity = agg.total_affinity_all / total_events; let pass_rate = if agg.outcome_count == 0 { 0.0 } else { agg.success_sum / agg.outcome_count as f64 }; let normalized_gain = if agg.outcome_count > 0 { calculate_normalized_gain(pass_rate, baseline_pass_rate) } else { calculate_normalized_gain(avg_affinity, baseline_affinity) }; let avg_context_chars = agg.total_context_chars as f64 / agg.count as f64; let score = if agg.outcome_count > 0 { (normalized_gain * 100.0) + (pass_rate * 25.0) + (agg.count.min(20) as f64 / 4.0) - (avg_context_chars / 500.0) } else { (normalized_gain * 100.0) + (agg.count.min(20) as f64 / 4.0) - (avg_context_chars / 500.0) }; Some(CodexSkillScore { name, sample_size: agg.count, outcome_samples: agg.outcome_count, pass_rate, avg_affinity, baseline_affinity, normalized_gain, avg_context_chars, score, }) }) .collect::<Vec<_>>(); skills.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); let scorecard = CodexSkillScorecard { version: SKILL_EVAL_VERSION, generated_at_unix: unix_now(), target_path: target_path.display().to_string(), samples: events.len(), skills, }; scorecard } pub fn load_scorecard(target_path: &Path) -> Result<Option<CodexSkillScorecard>> { for root in skill_eval_roots() { let path = root .join("scorecards") .join(format!("{}.json", scorecard_key(target_path))); if !path.exists() { continue; } let raw = fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; let scorecard = serde_json::from_slice::<CodexSkillScorecard>(&raw) .with_context(|| format!("failed to decode {}", path.display()))?; return Ok(Some(scorecard)); } Ok(None) } fn visit_lines_reverse<T, F>( path: &Path, max_items: usize, mut on_line: F, ) -> Result<Option<Vec<T>>> where F: FnMut(&str) -> Option<T>, { if max_items == 0 { return Ok(Some(Vec::new())); } let mut file = File::open(path).with_context(|| format!("failed to read {}", path.display()))?; let mut pos = file.seek(SeekFrom::End(0))?; if pos == 0 { return Ok(None); } let mut chunk = vec![0u8; SKILL_EVAL_REVERSE_SCAN_CHUNK_BYTES]; let mut carry = Vec::new(); let mut values = Vec::new(); while pos > 0 && values.len() < max_items { let read_len = usize::try_from(pos.min(chunk.len() as u64)).unwrap_or(chunk.len()); pos -= read_len as u64; file.seek(SeekFrom::Start(pos))?; file.read_exact(&mut chunk[..read_len]) .with_context(|| format!("failed to read {}", path.display()))?; let buf = &chunk[..read_len]; let mut end = read_len; while let Some(idx) = buf[..end].iter().rposition(|&byte| byte == b'\n') { if let Some(value) = process_reverse_line_segment(&buf[idx + 1..end], &mut carry, &mut on_line) { values.push(value); if values.len() >= max_items { return Ok(Some(values)); } } end = idx; } if end > 0 { let mut combined = Vec::with_capacity(end + carry.len()); combined.extend_from_slice(&buf[..end]); combined.extend_from_slice(&carry); carry = combined; } } if values.len() < max_items && !carry.is_empty() && let Ok(line) = std::str::from_utf8(&carry) && let Some(value) = on_line(line.trim_end_matches('\r')) { values.push(value); } if values.is_empty() { Ok(None) } else { Ok(Some(values)) } } fn process_reverse_line_segment<T, F>( segment: &[u8], carry: &mut Vec<u8>, on_line: &mut F, ) -> Option<T> where F: FnMut(&str) -> Option<T>, { if carry.is_empty() { let line = std::str::from_utf8(segment).ok()?; return on_line(line.trim_end_matches('\r')); } let suffix = std::mem::take(carry); let mut line_bytes = Vec::with_capacity(segment.len() + suffix.len()); line_bytes.extend_from_slice(segment); line_bytes.extend_from_slice(&suffix); let line = std::str::from_utf8(&line_bytes).ok()?; on_line(line.trim_end_matches('\r')) } pub fn score_for_skill(target_path: &Path, name: &str) -> Option<f64> { load_scorecard(target_path) .ok() .flatten() .and_then(|scorecard| { scorecard .skills .into_iter() .find(|skill| skill.name == name) .map(|skill| skill.score) }) } pub fn format_scorecard(scorecard: &CodexSkillScorecard) -> String { if scorecard.skills.is_empty() { return format!( "# codex skill-eval\n\ target: {}\n\ samples: {}\n\ skills: 0", scorecard.target_path, scorecard.samples ); } let mut lines = vec![ "# codex skill-eval".to_string(), format!("target: {}", scorecard.target_path), format!("samples: {}", scorecard.samples), ]; for skill in &scorecard.skills { lines.push(format!( "- {} | score {:.2} | gain {:.3} | samples {} | outcomes {} | pass {:.2} | ctx {:.0} chars", skill.name, skill.score, skill.normalized_gain, skill.sample_size, skill.outcome_samples, skill.pass_rate, skill.avg_context_chars )); } lines.join("\n") } #[cfg(test)] mod tests { use super::*; #[test] fn normalized_gain_behaves_like_skillgrade_formula() { let gain = calculate_normalized_gain(0.8, 0.5); assert!((gain - 0.6).abs() < 0.0001); } #[test] fn affinity_prefers_phrase_matches() { assert!( affinity_for_skill("find-skills", "please find skills for react") >= affinity_for_skill("find-skills", "please help with react") ); } #[test] fn load_events_reads_both_state_roots() { let dir = tempfile::tempdir().expect("tempdir"); let legacy_path = dir.path().join("legacy"); let current_path = dir.path().join("current"); fs::create_dir_all(&legacy_path).expect("legacy dir"); fs::create_dir_all(¤t_path).expect("current dir"); let event = CodexSkillEvalEvent { version: 1, recorded_at_unix: 1, mode: "resolve".to_string(), action: "new".to_string(), route: "new-plain".to_string(), target_path: "/tmp/repo".to_string(), launch_path: "/tmp/repo".to_string(), query: "write plan".to_string(), session_id: None, runtime_token: Some("tok".to_string()), runtime_skills: vec!["plan_write".to_string()], prompt_context_budget_chars: 400, prompt_chars: 100, injected_context_chars: 30, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }; fs::write( legacy_path.join("events.jsonl"), serde_json::to_string(&event).expect("encode") + "\n", ) .expect("legacy events"); fs::write( current_path.join("events.jsonl"), serde_json::to_string(&CodexSkillEvalEvent { recorded_at_unix: 2, ..event.clone() }) .expect("encode") + "\n", ) .expect("current events"); let loaded = load_events_from_paths( vec![ legacy_path.join("events.jsonl"), current_path.join("events.jsonl"), ], None, 10, ) .expect("load"); assert_eq!(loaded.len(), 2); assert_eq!(loaded[0].recorded_at_unix, 2); } #[test] fn load_events_sanitizes_contextual_queries() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("events.jsonl"); let event = CodexSkillEvalEvent { version: 1, recorded_at_unix: 1, mode: "quick-launch".to_string(), action: "resume".to_string(), route: "quick-launch-hydrated".to_string(), target_path: "/tmp/repo".to_string(), launch_path: "/tmp/repo".to_string(), query: "# AGENTS.md instructions for /tmp\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>\n<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>\nwrite plan".to_string(), session_id: Some("sess-1".to_string()), runtime_token: None, runtime_skills: Vec::new(), prompt_context_budget_chars: 0, prompt_chars: 10, injected_context_chars: 0, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }; fs::write(&path, serde_json::to_string(&event).expect("encode") + "\n").expect("write"); let loaded = load_events_from_paths(vec![path], None, 10).expect("load"); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].query, "write plan"); } #[test] fn resolve_events_contribute_outcome_samples() { let target = Path::new("/tmp/repo"); let scorecard = build_scorecard( target, vec![CodexSkillEvalEvent { version: 1, recorded_at_unix: 1, mode: "resolve".to_string(), action: "new".to_string(), route: "new-plain".to_string(), target_path: target.display().to_string(), launch_path: target.display().to_string(), query: "write plan".to_string(), session_id: None, runtime_token: Some("tok".to_string()), runtime_skills: vec!["plan_write".to_string()], prompt_context_budget_chars: 400, prompt_chars: 100, injected_context_chars: 30, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }], vec![CodexSkillOutcomeEvent { version: 1, recorded_at_unix: 2, runtime_token: Some("tok".to_string()), session_id: None, target_path: Some(target.display().to_string()), kind: "plan_written".to_string(), skill_names: vec!["plan_write".to_string()], artifact_path: Some("/tmp/repo/plan.md".to_string()), success: 1.0, trace_id: None, span_id: None, parent_span_id: None, service_name: None, }], ); assert_eq!(scorecard.samples, 1); assert_eq!(scorecard.skills.len(), 1); assert_eq!(scorecard.skills[0].name, "plan_write"); assert_eq!(scorecard.skills[0].outcome_samples, 1); assert_eq!(scorecard.skills[0].pass_rate, 1.0); } #[test] fn session_linked_events_contribute_baseline_outcomes() { let target = Path::new("/tmp/repo"); let scorecard = build_scorecard( target, vec![CodexSkillEvalEvent { version: 1, recorded_at_unix: 1, mode: "quick-launch".to_string(), action: "resume".to_string(), route: "quick-launch-hydrated".to_string(), target_path: target.display().to_string(), launch_path: target.display().to_string(), query: "write plan".to_string(), session_id: Some("sess-1".to_string()), runtime_token: None, runtime_skills: vec!["plan_write".to_string()], prompt_context_budget_chars: 0, prompt_chars: 10, injected_context_chars: 0, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }], vec![CodexSkillOutcomeEvent { version: 1, recorded_at_unix: 2, runtime_token: None, session_id: Some("sess-1".to_string()), target_path: Some(target.display().to_string()), kind: "plan_written".to_string(), skill_names: vec!["plan_write".to_string()], artifact_path: Some("/tmp/repo/plan.md".to_string()), success: 1.0, trace_id: None, span_id: None, parent_span_id: None, service_name: None, }], ); assert_eq!(scorecard.skills.len(), 1); assert_eq!(scorecard.skills[0].outcome_samples, 1); assert_eq!(scorecard.skills[0].pass_rate, 1.0); } #[test] fn recent_targets_filters_old_missing_and_excess_targets() { let dir = tempfile::tempdir().expect("tempdir"); let repo_a = dir.path().join("repo-a"); let repo_b = dir.path().join("repo-b"); fs::create_dir_all(&repo_a).expect("repo a"); fs::create_dir_all(&repo_b).expect("repo b"); let now = unix_now(); let targets = collect_recent_targets( vec![ CodexSkillEvalEvent { version: 1, recorded_at_unix: now, mode: "resolve".to_string(), action: "new".to_string(), route: "new-plain".to_string(), target_path: repo_a.display().to_string(), launch_path: repo_a.display().to_string(), query: "write plan".to_string(), session_id: None, runtime_token: Some("a".to_string()), runtime_skills: vec!["plan_write".to_string()], prompt_context_budget_chars: 400, prompt_chars: 100, injected_context_chars: 30, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }, CodexSkillEvalEvent { version: 1, recorded_at_unix: now.saturating_sub(60), mode: "resolve".to_string(), action: "new".to_string(), route: "new-plain".to_string(), target_path: repo_b.display().to_string(), launch_path: repo_b.display().to_string(), query: "find skills".to_string(), session_id: None, runtime_token: Some("b".to_string()), runtime_skills: vec!["find-skills".to_string()], prompt_context_budget_chars: 400, prompt_chars: 100, injected_context_chars: 30, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }, CodexSkillEvalEvent { version: 1, recorded_at_unix: now.saturating_sub(60 * 60 * 24 * 10), mode: "resolve".to_string(), action: "new".to_string(), route: "new-plain".to_string(), target_path: dir.path().join("missing").display().to_string(), launch_path: dir.path().join("missing").display().to_string(), query: "old".to_string(), session_id: None, runtime_token: Some("c".to_string()), runtime_skills: vec!["plan_write".to_string()], prompt_context_budget_chars: 400, prompt_chars: 100, injected_context_chars: 30, reference_count: 0, trace_id: None, span_id: None, parent_span_id: None, workflow_kind: None, service_name: None, }, ], 1, 24, ); assert_eq!(targets, vec![repo_a]); } } ================================================ FILE: src/codex_telemetry.rs ================================================ use std::collections::hash_map::DefaultHasher; use std::fs::{self, File}; use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use reqwest::blocking::Client; use seq_everruns_bridge::maple::{ MapleExporterConfig, MapleIngestTarget, MapleSpan, MapleTraceExporter, }; use serde::{Deserialize, Serialize}; use crate::codex_skill_eval::{self, CodexSkillEvalEvent, CodexSkillOutcomeEvent}; use crate::config; use crate::env as flow_env; const CODEX_MAPLE_DEFAULT_SERVICE_NAME: &str = "flow-codex"; const CODEX_MAPLE_DEFAULT_SCOPE_NAME: &str = "flow.codex"; const CODEX_MAPLE_DEFAULT_ENV: &str = "local"; const CODEX_MAPLE_DEFAULT_QUEUE_CAPACITY: usize = 1024; const CODEX_MAPLE_DEFAULT_MAX_BATCH_SIZE: usize = 64; const CODEX_MAPLE_DEFAULT_FLUSH_INTERVAL_MS: u64 = 100; const CODEX_MAPLE_DEFAULT_CONNECT_TIMEOUT_MS: u64 = 400; const CODEX_MAPLE_DEFAULT_REQUEST_TIMEOUT_MS: u64 = 800; const DEFAULT_MAPLE_MCP_ENDPOINT: &str = "https://api.maple.dev/mcp"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] struct CodexTelemetryExportState { version: u32, events_offset: u64, outcomes_offset: u64, events_exported: u64, outcomes_exported: u64, last_exported_at_unix: Option<u64>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexTelemetryStatus { pub enabled: bool, pub configured_targets: usize, pub service_name: String, pub scope_name: String, pub state_path: String, pub events_path: String, pub outcomes_path: String, pub events_offset: u64, pub outcomes_offset: u64, pub events_exported: u64, pub outcomes_exported: u64, pub last_exported_at_unix: Option<u64>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexTelemetryFlushSummary { pub enabled: bool, pub configured_targets: usize, pub events_seen: usize, pub outcomes_seen: usize, pub events_exported: usize, pub outcomes_exported: usize, pub state_path: String, pub last_exported_at_unix: Option<u64>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexTraceStatus { pub enabled: bool, pub endpoint: String, pub token_source: String, pub tools_list_ok: bool, pub tools_count: usize, pub read_probe_ok: bool, pub read_probe_error: Option<String>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexTraceInspectResult { pub trace_id: String, pub endpoint: String, pub token_source: String, pub flushed: bool, pub result: Option<serde_json::Value>, pub read_error: Option<String>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexCurrentSessionTrace { pub trace_id: String, pub span_id: Option<String>, pub parent_span_id: Option<String>, pub workflow_kind: Option<String>, pub service_name: Option<String>, pub flushed: bool, pub endpoint: String, pub token_source: String, pub result: Option<serde_json::Value>, pub read_error: Option<String>, } #[derive(Debug, Clone)] struct MapleReadConfig { endpoint: String, token: String, token_source: String, connect_timeout_ms: u64, request_timeout_ms: u64, } fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0) } fn env_non_empty(key: &str) -> Option<String> { std::env::var(key) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn telemetry_env_keys() -> Vec<String> { [ "FLOW_CODEX_MAPLE_LOCAL_ENDPOINT", "FLOW_CODEX_MAPLE_LOCAL_INGEST_KEY", "FLOW_CODEX_MAPLE_HOSTED_ENDPOINT", "FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY", "FLOW_CODEX_MAPLE_TRACES_ENDPOINTS", "FLOW_CODEX_MAPLE_INGEST_KEYS", "FLOW_CODEX_MAPLE_SERVICE_NAME", "FLOW_CODEX_MAPLE_SERVICE_VERSION", "FLOW_CODEX_MAPLE_SCOPE_NAME", "FLOW_CODEX_MAPLE_ENV", "FLOW_CODEX_MAPLE_QUEUE_CAPACITY", "FLOW_CODEX_MAPLE_MAX_BATCH_SIZE", "FLOW_CODEX_MAPLE_FLUSH_INTERVAL_MS", "FLOW_CODEX_MAPLE_CONNECT_TIMEOUT_MS", "FLOW_CODEX_MAPLE_REQUEST_TIMEOUT_MS", "MAPLE_API_TOKEN", "MAPLE_MCP_URL", ] .into_iter() .map(str::to_string) .collect() } fn maple_target_env_keys() -> &'static [&'static str] { &[ "FLOW_CODEX_MAPLE_LOCAL_ENDPOINT", "FLOW_CODEX_MAPLE_LOCAL_INGEST_KEY", "FLOW_CODEX_MAPLE_HOSTED_ENDPOINT", "FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY", "FLOW_CODEX_MAPLE_TRACES_ENDPOINTS", "FLOW_CODEX_MAPLE_INGEST_KEYS", ] } fn shell_has_explicit_maple_target_env() -> bool { maple_target_env_keys() .iter() .any(|key| env_non_empty(key).is_some()) } fn env_non_empty_with_store( key: &str, personal_env: &mut Option<Option<std::collections::HashMap<String, String>>>, ) -> Option<String> { if let Some(value) = env_non_empty(key) { return Some(value); } if personal_env.is_none() { *personal_env = Some(flow_env::fetch_local_personal_env_vars(&telemetry_env_keys()).ok()); } personal_env .as_ref() .and_then(|cached| cached.as_ref()) .and_then(|values| values.get(key)) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn telemetry_state_path() -> Result<PathBuf> { let root = config::ensure_global_state_dir()?.join("codex"); fs::create_dir_all(&root)?; Ok(root.join("telemetry-export-state.json")) } fn load_state() -> Result<CodexTelemetryExportState> { let path = telemetry_state_path()?; if !path.exists() { return Ok(CodexTelemetryExportState { version: 1, ..Default::default() }); } let raw = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let mut state: CodexTelemetryExportState = serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?; if state.version == 0 { state.version = 1; } Ok(state) } fn save_state(state: &CodexTelemetryExportState) -> Result<()> { let path = telemetry_state_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } fs::write( &path, serde_json::to_vec_pretty(state).context("failed to encode telemetry state")?, ) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn parse_maple_exporter_config_from_env() -> Result<Option<MapleExporterConfig>> { let allow_store_fallback = !shell_has_explicit_maple_target_env(); let mut personal_env = if allow_store_fallback { None } else { Some(None) }; let mut targets = Vec::new(); match ( env_non_empty_with_store("FLOW_CODEX_MAPLE_LOCAL_ENDPOINT", &mut personal_env), env_non_empty_with_store("FLOW_CODEX_MAPLE_LOCAL_INGEST_KEY", &mut personal_env), ) { (Some(endpoint), Some(key)) => targets.push(MapleIngestTarget { traces_endpoint: endpoint, ingest_key: key, }), (None, None) => {} _ => anyhow::bail!("FLOW_CODEX_MAPLE_LOCAL_ENDPOINT and FLOW_CODEX_MAPLE_LOCAL_INGEST_KEY must both be set"), } match ( env_non_empty_with_store("FLOW_CODEX_MAPLE_HOSTED_ENDPOINT", &mut personal_env), env_non_empty_with_store("FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY", &mut personal_env), ) { (Some(endpoint), Some(key)) => targets.push(MapleIngestTarget { traces_endpoint: endpoint, ingest_key: key, }), (None, None) => {} _ => anyhow::bail!("FLOW_CODEX_MAPLE_HOSTED_ENDPOINT and FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY must both be set"), } let csv_endpoints = env_non_empty_with_store("FLOW_CODEX_MAPLE_TRACES_ENDPOINTS", &mut personal_env) .map(|raw| { raw.split(',') .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect::<Vec<_>>() }) .unwrap_or_default(); let csv_keys = env_non_empty_with_store("FLOW_CODEX_MAPLE_INGEST_KEYS", &mut personal_env) .map(|raw| { raw.split(',') .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect::<Vec<_>>() }) .unwrap_or_default(); if !csv_endpoints.is_empty() || !csv_keys.is_empty() { if csv_endpoints.len() != csv_keys.len() { anyhow::bail!( "FLOW_CODEX_MAPLE_TRACES_ENDPOINTS count ({}) does not match FLOW_CODEX_MAPLE_INGEST_KEYS count ({})", csv_endpoints.len(), csv_keys.len() ); } for (endpoint, key) in csv_endpoints.into_iter().zip(csv_keys.into_iter()) { targets.push(MapleIngestTarget { traces_endpoint: endpoint, ingest_key: key, }); } } if targets.is_empty() { return Ok(None); } targets.dedup_by(|a, b| { a.traces_endpoint == b.traces_endpoint && a.ingest_key == b.ingest_key }); Ok(Some(MapleExporterConfig { service_name: env_non_empty("FLOW_CODEX_MAPLE_SERVICE_NAME") .unwrap_or_else(|| CODEX_MAPLE_DEFAULT_SERVICE_NAME.to_string()), service_version: env_non_empty("FLOW_CODEX_MAPLE_SERVICE_VERSION"), deployment_environment: env_non_empty("FLOW_CODEX_MAPLE_ENV") .unwrap_or_else(|| CODEX_MAPLE_DEFAULT_ENV.to_string()), scope_name: env_non_empty("FLOW_CODEX_MAPLE_SCOPE_NAME") .unwrap_or_else(|| CODEX_MAPLE_DEFAULT_SCOPE_NAME.to_string()), queue_capacity: env_non_empty("FLOW_CODEX_MAPLE_QUEUE_CAPACITY") .and_then(|value| value.parse::<usize>().ok()) .unwrap_or(CODEX_MAPLE_DEFAULT_QUEUE_CAPACITY) .max(1), max_batch_size: env_non_empty("FLOW_CODEX_MAPLE_MAX_BATCH_SIZE") .and_then(|value| value.parse::<usize>().ok()) .unwrap_or(CODEX_MAPLE_DEFAULT_MAX_BATCH_SIZE) .max(1), flush_interval: std::time::Duration::from_millis( env_non_empty("FLOW_CODEX_MAPLE_FLUSH_INTERVAL_MS") .and_then(|value| value.parse::<u64>().ok()) .unwrap_or(CODEX_MAPLE_DEFAULT_FLUSH_INTERVAL_MS), ), connect_timeout: std::time::Duration::from_millis( env_non_empty("FLOW_CODEX_MAPLE_CONNECT_TIMEOUT_MS") .and_then(|value| value.parse::<u64>().ok()) .unwrap_or(CODEX_MAPLE_DEFAULT_CONNECT_TIMEOUT_MS), ), request_timeout: std::time::Duration::from_millis( env_non_empty("FLOW_CODEX_MAPLE_REQUEST_TIMEOUT_MS") .and_then(|value| value.parse::<u64>().ok()) .unwrap_or(CODEX_MAPLE_DEFAULT_REQUEST_TIMEOUT_MS), ), targets, })) } fn parse_maple_read_config_from_env() -> Result<Option<MapleReadConfig>> { let allow_store_fallback = env_non_empty("MAPLE_API_TOKEN").is_none() && env_non_empty("FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY").is_none(); let mut personal_env = if allow_store_fallback { None } else { Some(None) }; let shell_token = env_non_empty("MAPLE_API_TOKEN") .map(|value| (value, "shell".to_string())) .or_else(|| { env_non_empty("FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY") .map(|value| (value, "shell-ingest-key".to_string())) }); let store_token = env_non_empty_with_store("MAPLE_API_TOKEN", &mut personal_env) .map(|value| (value, "flow-personal-env".to_string())) .or_else(|| { env_non_empty_with_store("FLOW_CODEX_MAPLE_HOSTED_INGEST_KEY", &mut personal_env) .map(|value| (value, "flow-personal-ingest-key".to_string())) }); let token = shell_token.or(store_token); let endpoint = env_non_empty_with_store("MAPLE_MCP_URL", &mut personal_env) .unwrap_or_else(|| DEFAULT_MAPLE_MCP_ENDPOINT.to_string()); let Some((token, token_source)) = token else { return Ok(None); }; Ok(Some(MapleReadConfig { endpoint, token, token_source, connect_timeout_ms: env_non_empty("FLOW_CODEX_MAPLE_CONNECT_TIMEOUT_MS") .and_then(|value| value.parse::<u64>().ok()) .unwrap_or(CODEX_MAPLE_DEFAULT_CONNECT_TIMEOUT_MS), request_timeout_ms: env_non_empty("FLOW_CODEX_MAPLE_REQUEST_TIMEOUT_MS") .and_then(|value| value.parse::<u64>().ok()) .unwrap_or(CODEX_MAPLE_DEFAULT_REQUEST_TIMEOUT_MS), })) } fn maple_json_rpc_request( config: &MapleReadConfig, method: &str, params: serde_json::Value, ) -> Result<serde_json::Value> { let client = Client::builder() .connect_timeout(std::time::Duration::from_millis(config.connect_timeout_ms)) .timeout(std::time::Duration::from_millis(config.request_timeout_ms)) .build() .context("failed to build Maple MCP client")?; let request = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params, }); let response = client .post(&config.endpoint) .bearer_auth(&config.token) .json(&request) .send() .with_context(|| format!("failed to reach Maple MCP at {}", config.endpoint))?; let status = response.status(); let payload: serde_json::Value = response .json() .context("failed to parse Maple MCP response JSON")?; if !status.is_success() { anyhow::bail!( "Maple MCP request failed ({}): {}", status, serde_json::to_string(&payload).unwrap_or_else(|_| "unparseable error body".to_string()) ); } let envelope = if let Some(items) = payload.as_array() { items.first().cloned().unwrap_or(serde_json::Value::Null) } else { payload }; if let Some(error) = envelope.get("error") { let code = error .get("code") .and_then(serde_json::Value::as_i64) .unwrap_or(-1); let message = error .get("message") .and_then(serde_json::Value::as_str) .unwrap_or("unknown Maple MCP error"); anyhow::bail!("Maple MCP error {code}: {message}"); } envelope .get("result") .cloned() .ok_or_else(|| anyhow::anyhow!("Maple MCP response did not include a result payload")) } fn maple_tool_result_error(result: &serde_json::Value) -> Option<String> { if result.get("isError").and_then(|value| value.as_bool()) != Some(true) { return None; } result .get("content") .and_then(|value| value.as_array()) .and_then(|items| items.first()) .and_then(|item| item.get("text")) .and_then(|value| value.as_str()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .or_else(|| Some("Maple tool returned an unspecified error".to_string())) } fn maple_call_tool( config: &MapleReadConfig, name: &str, arguments: serde_json::Value, ) -> Result<serde_json::Value> { let result = maple_json_rpc_request( config, "tools/call", serde_json::json!({ "name": name, "arguments": arguments, }), )?; if let Some(error) = maple_tool_result_error(&result) { anyhow::bail!("{error}"); } Ok(result) } pub fn status() -> Result<CodexTelemetryStatus> { let config = parse_maple_exporter_config_from_env()?; let state = load_state()?; let state_path = telemetry_state_path()?; let events_path = codex_skill_eval::events_log_path()?; let outcomes_path = codex_skill_eval::outcomes_log_path()?; Ok(CodexTelemetryStatus { enabled: config.is_some(), configured_targets: config.as_ref().map(|value| value.targets.len()).unwrap_or(0), service_name: config .as_ref() .map(|value| value.service_name.clone()) .unwrap_or_else(|| CODEX_MAPLE_DEFAULT_SERVICE_NAME.to_string()), scope_name: config .as_ref() .map(|value| value.scope_name.clone()) .unwrap_or_else(|| CODEX_MAPLE_DEFAULT_SCOPE_NAME.to_string()), state_path: state_path.display().to_string(), events_path: events_path.display().to_string(), outcomes_path: outcomes_path.display().to_string(), events_offset: state.events_offset, outcomes_offset: state.outcomes_offset, events_exported: state.events_exported, outcomes_exported: state.outcomes_exported, last_exported_at_unix: state.last_exported_at_unix, }) } pub fn trace_status() -> Result<CodexTraceStatus> { let Some(config) = parse_maple_read_config_from_env()? else { return Ok(CodexTraceStatus { enabled: false, endpoint: DEFAULT_MAPLE_MCP_ENDPOINT.to_string(), token_source: "missing".to_string(), tools_list_ok: false, tools_count: 0, read_probe_ok: false, read_probe_error: Some("MAPLE_API_TOKEN is not configured".to_string()), }); }; let list_result = maple_json_rpc_request(&config, "tools/list", serde_json::json!({}))?; let tools = list_result .get("tools") .and_then(|value| value.as_array()) .cloned() .unwrap_or_default(); let read_probe = maple_json_rpc_request( &config, "tools/call", serde_json::json!({ "name": "system_health", "arguments": {}, }), ); Ok(CodexTraceStatus { enabled: true, endpoint: config.endpoint, token_source: config.token_source, tools_list_ok: true, tools_count: tools.len(), read_probe_ok: read_probe .as_ref() .ok() .and_then(maple_tool_result_error) .is_none(), read_probe_error: match read_probe { Ok(value) => maple_tool_result_error(&value), Err(error) => Some(error.to_string()), }, }) } pub fn inspect_trace(trace_id: &str, flush_first: bool) -> Result<CodexTraceInspectResult> { let Some(config) = parse_maple_read_config_from_env()? else { anyhow::bail!("MAPLE_API_TOKEN is not configured"); }; let flushed = if flush_first { let _ = flush(64); true } else { false }; let result = maple_call_tool( &config, "inspect_trace", serde_json::json!({ "trace_id": trace_id, }), ); let (result, read_error) = match result { Ok(result) => (Some(result), None), Err(error) => (None, Some(error.to_string())), }; Ok(CodexTraceInspectResult { trace_id: trace_id.to_string(), endpoint: config.endpoint, token_source: config.token_source, flushed, result, read_error, }) } pub fn inspect_current_session_trace(flush_first: bool) -> Result<CodexCurrentSessionTrace> { let trace_id = env_non_empty("FLOW_TRACE_ID").ok_or_else(|| { anyhow::anyhow!( "FLOW_TRACE_ID is not set; start or resume the Codex session through Flow (`j`, `k`, or `f codex ...`)" ) })?; let span_id = env_non_empty("FLOW_SPAN_ID"); let parent_span_id = env_non_empty("FLOW_PARENT_SPAN_ID"); let workflow_kind = env_non_empty("FLOW_WORKFLOW_KIND"); let service_name = env_non_empty("FLOW_TRACE_SERVICE_NAME"); let inspected = inspect_trace(&trace_id, flush_first)?; Ok(CodexCurrentSessionTrace { trace_id, span_id, parent_span_id, workflow_kind, service_name, flushed: inspected.flushed, endpoint: inspected.endpoint, token_source: inspected.token_source, result: inspected.result, read_error: inspected.read_error, }) } fn stable_hex_id(parts: &[&str], width: usize) -> String { let mut out = String::new(); let needed = width.div_ceil(16); for seed in 0..needed { let mut hasher = DefaultHasher::new(); seed.hash(&mut hasher); for part in parts { part.hash(&mut hasher); } out.push_str(&format!("{:016x}", hasher.finish())); } out.truncate(width); out } fn redact_id(value: Option<&str>) -> String { value .filter(|candidate| !candidate.trim().is_empty()) .map(|candidate| stable_hex_id(&[candidate], 16)) .unwrap_or_else(|| "none".to_string()) } fn repo_name(path: &str) -> String { Path::new(path) .file_name() .and_then(|value| value.to_str()) .filter(|value| !value.is_empty()) .unwrap_or("unknown") .to_string() } fn path_hash(path: &str) -> String { stable_hex_id(&[path], 16) } fn artifact_name(path: Option<&str>) -> String { path.and_then(|value| Path::new(value).file_name()) .and_then(|value| value.to_str()) .filter(|value| !value.is_empty()) .unwrap_or("none") .to_string() } fn event_span(event: &CodexSkillEvalEvent) -> MapleSpan { let session_seed = event .session_id .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(event.target_path.as_str()); let event_seed = format!( "eval:{}:{}:{}:{}", event.recorded_at_unix, event.mode, event.route, event.action ); let start_time_unix_nano = event.recorded_at_unix.saturating_mul(1_000_000_000); let end_time_unix_nano = start_time_unix_nano.saturating_add(1_000_000); MapleSpan { trace_id: event .trace_id .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| value.to_string()) .unwrap_or_else(|| stable_hex_id(&[session_seed], 32)), span_id: event .span_id .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| value.to_string()) .unwrap_or_else(|| stable_hex_id(&[session_seed, &event_seed], 16)), parent_span_id: event.parent_span_id.clone().unwrap_or_default(), name: event .workflow_kind .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| format!("flow.codex.{value}")) .unwrap_or_else(|| "flow.codex.launch".to_string()), kind: 1, start_time_unix_nano, end_time_unix_nano, status_code: 1, status_message: None, attributes: vec![ ("event.kind".to_string(), "codex_skill_eval".to_string()), ("mode".to_string(), event.mode.clone()), ("action".to_string(), event.action.clone()), ("route".to_string(), event.route.clone()), ("target.repo".to_string(), repo_name(&event.target_path)), ("target.path_hash".to_string(), path_hash(&event.target_path)), ("launch.path_hash".to_string(), path_hash(&event.launch_path)), ("session.hash".to_string(), redact_id(event.session_id.as_deref())), ( "runtime.skill_count".to_string(), event.runtime_skills.len().to_string(), ), ( "trace.workflow_kind".to_string(), event .workflow_kind .clone() .unwrap_or_else(|| "launch".to_string()), ), ( "trace.service_name".to_string(), event .service_name .clone() .unwrap_or_else(|| CODEX_MAPLE_DEFAULT_SERVICE_NAME.to_string()), ), ( "runtime.skills".to_string(), if event.runtime_skills.is_empty() { "none".to_string() } else { event.runtime_skills.join(",") }, ), ( "prompt.context_budget_chars".to_string(), event.prompt_context_budget_chars.to_string(), ), ("prompt.chars".to_string(), event.prompt_chars.to_string()), ( "prompt.injected_context_chars".to_string(), event.injected_context_chars.to_string(), ), ( "prompt.reference_count".to_string(), event.reference_count.to_string(), ), ], } } fn outcome_span(outcome: &CodexSkillOutcomeEvent) -> MapleSpan { let target_seed = outcome .target_path .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("unknown-target"); let outcome_seed = format!( "outcome:{}:{}:{:.3}", outcome.recorded_at_unix, outcome.kind, outcome.success ); let start_time_unix_nano = outcome.recorded_at_unix.saturating_mul(1_000_000_000); let end_time_unix_nano = start_time_unix_nano.saturating_add(1_000_000); MapleSpan { trace_id: outcome .trace_id .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| value.to_string()) .unwrap_or_else(|| { stable_hex_id( &[outcome.session_id.as_deref().unwrap_or(target_seed), "outcome"], 32, ) }), span_id: outcome .span_id .as_deref() .filter(|value| !value.trim().is_empty()) .map(|value| value.to_string()) .unwrap_or_else(|| stable_hex_id(&[target_seed, &outcome_seed], 16)), parent_span_id: outcome.parent_span_id.clone().unwrap_or_default(), name: outcome .service_name .as_deref() .filter(|value| !value.trim().is_empty()) .map(|_| "flow.codex.outcome".to_string()) .unwrap_or_else(|| "flow.codex.outcome".to_string()), kind: 1, start_time_unix_nano, end_time_unix_nano, status_code: if outcome.success >= 0.5 { 1 } else { 2 }, status_message: None, attributes: vec![ ("event.kind".to_string(), "codex_skill_outcome".to_string()), ("kind".to_string(), outcome.kind.clone()), ("success".to_string(), format!("{:.3}", outcome.success)), ( "skill_names".to_string(), if outcome.skill_names.is_empty() { "none".to_string() } else { outcome.skill_names.join(",") }, ), ( "session.hash".to_string(), redact_id(outcome.session_id.as_deref()), ), ( "target.repo".to_string(), outcome .target_path .as_deref() .map(repo_name) .unwrap_or_else(|| "unknown".to_string()), ), ( "target.path_hash".to_string(), outcome .target_path .as_deref() .map(path_hash) .unwrap_or_else(|| "none".to_string()), ), ( "artifact.name".to_string(), artifact_name(outcome.artifact_path.as_deref()), ), ( "artifact.hash".to_string(), outcome .artifact_path .as_deref() .map(path_hash) .unwrap_or_else(|| "none".to_string()), ), ( "trace.service_name".to_string(), outcome .service_name .clone() .unwrap_or_else(|| CODEX_MAPLE_DEFAULT_SERVICE_NAME.to_string()), ), ], } } fn export_lines<T, F, E>( path: &Path, offset: &mut u64, remaining: &mut usize, mut parse_line: F, mut emit: E, ) -> Result<(usize, usize)> where F: FnMut(&str) -> Option<T>, E: FnMut(T), { if *remaining == 0 || !path.exists() { return Ok((0, 0)); } let metadata = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; if *offset > metadata.len() { *offset = 0; } let mut file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; file.seek(SeekFrom::Start(*offset)) .with_context(|| format!("failed to seek {}", path.display()))?; let mut reader = BufReader::new(file); let mut line = String::new(); let mut seen = 0usize; let mut exported = 0usize; while *remaining > 0 { line.clear(); let bytes = reader .read_line(&mut line) .with_context(|| format!("failed reading {}", path.display()))?; if bytes == 0 { break; } *offset = (*offset).saturating_add(bytes as u64); let trimmed = line.trim(); if trimmed.is_empty() { continue; } seen += 1; if let Some(item) = parse_line(trimmed) { emit(item); exported += 1; } *remaining = remaining.saturating_sub(1); } Ok((seen, exported)) } pub fn flush(limit: usize) -> Result<CodexTelemetryFlushSummary> { let config = parse_maple_exporter_config_from_env()?; let state_path = telemetry_state_path()?; let Some(config) = config else { return Ok(CodexTelemetryFlushSummary { enabled: false, configured_targets: 0, events_seen: 0, outcomes_seen: 0, events_exported: 0, outcomes_exported: 0, state_path: state_path.display().to_string(), last_exported_at_unix: None, }); }; let exporter = MapleTraceExporter::new(config.clone()); let mut state = load_state()?; let events_path = codex_skill_eval::events_log_path()?; let outcomes_path = codex_skill_eval::outcomes_log_path()?; let mut remaining = limit.max(1); let (events_seen, events_exported) = export_lines( &events_path, &mut state.events_offset, &mut remaining, |line| serde_json::from_str::<CodexSkillEvalEvent>(line).ok(), |event| exporter.emit_span(event_span(&event)), )?; let (outcomes_seen, outcomes_exported) = export_lines( &outcomes_path, &mut state.outcomes_offset, &mut remaining, |line| serde_json::from_str::<CodexSkillOutcomeEvent>(line).ok(), |outcome| exporter.emit_span(outcome_span(&outcome)), )?; if events_seen > 0 || outcomes_seen > 0 { state.version = 1; state.events_exported = state.events_exported.saturating_add(events_exported as u64); state.outcomes_exported = state .outcomes_exported .saturating_add(outcomes_exported as u64); if events_exported > 0 || outcomes_exported > 0 { state.last_exported_at_unix = Some(unix_now()); } save_state(&state)?; } Ok(CodexTelemetryFlushSummary { enabled: true, configured_targets: config.targets.len(), events_seen, outcomes_seen, events_exported, outcomes_exported, state_path: state_path.display().to_string(), last_exported_at_unix: state.last_exported_at_unix, }) } pub fn maybe_flush(limit: usize) -> Result<usize> { let summary = flush(limit)?; Ok(summary.events_exported + summary.outcomes_exported) } #[cfg(test)] mod tests { use super::*; #[test] fn repo_name_uses_leaf_directory() { assert_eq!(repo_name("/Users/test/code/flow"), "flow"); assert_eq!(repo_name("flow"), "flow"); } #[test] fn redact_id_is_stable_and_short() { let first = redact_id(Some("session-123")); let second = redact_id(Some("session-123")); assert_eq!(first, second); assert_eq!(first.len(), 16); } } ================================================ FILE: src/codex_text.rs ================================================ fn strip_tagged_block(text: &str, open_tag: &str, close_tag: &str) -> String { let mut result = text.to_string(); while let Some(start) = result.find(open_tag) { if let Some(end) = result[start..].find(close_tag) { let end_pos = start + end + close_tag.len(); result = format!("{}{}", &result[..start], &result[end_pos..]); } else { result = result[..start].to_string(); break; } } result } fn strip_system_reminders(text: &str) -> String { strip_tagged_block(text, "<system-reminder>", "</system-reminder>") .trim() .to_string() } fn strip_agents_instruction_block(text: &str) -> String { let mut result = text.to_string(); loop { let agents_start = result .find("# AGENTS.md instructions for ") .or_else(|| result.find("# agents.md instructions for ")); let Some(start) = agents_start else { break; }; if let Some(end) = result[start..].find("</INSTRUCTIONS>") { let end_pos = start + end + "</INSTRUCTIONS>".len(); result = format!("{}{}", &result[..start], &result[end_pos..]); } else { result = result[..start].to_string(); break; } } result } fn truncate_before_heading(text: &str, heading: &str) -> String { let mut offset = 0usize; for line in text.lines() { if line.trim_start().starts_with(heading) { return text[..offset].trim().to_string(); } offset += line.len(); if offset < text.len() { offset += 1; } } text.trim().to_string() } fn collapse_blank_lines(text: &str) -> String { let mut out = String::new(); let mut saw_blank = false; for line in text.lines() { let trimmed = line.trim_end(); if trimmed.trim().is_empty() { if saw_blank || out.is_empty() { continue; } saw_blank = true; out.push('\n'); continue; } if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } out.push_str(trimmed); out.push('\n'); saw_blank = false; } out.trim().to_string() } pub(crate) fn sanitize_codex_memory_rollout_text(text: &str) -> Option<String> { let mut cleaned = strip_system_reminders(text); cleaned = strip_agents_instruction_block(&cleaned); cleaned = strip_tagged_block(&cleaned, "<skill>", "</skill>"); cleaned = collapse_blank_lines(&cleaned); let trimmed = cleaned.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } pub(crate) fn sanitize_codex_query_text(text: &str) -> Option<String> { let mut cleaned = sanitize_codex_memory_rollout_text(text)?; cleaned = strip_tagged_block(&cleaned, "<environment_context>", "</environment_context>"); cleaned = strip_tagged_block( &cleaned, "<permissions instructions>", "</permissions instructions>", ); cleaned = strip_tagged_block(&cleaned, "<collaboration_mode>", "</collaboration_mode>"); cleaned = strip_tagged_block( &cleaned, "<subagent_notification>", "</subagent_notification>", ); cleaned = truncate_before_heading(&cleaned, "Workflow context:"); cleaned = truncate_before_heading(&cleaned, "Start by checking:"); cleaned = truncate_before_heading(&cleaned, "Designer stack notes:"); cleaned = collapse_blank_lines(&cleaned); let trimmed = cleaned.trim(); if trimmed.is_empty() || trimmed.starts_with("<environment_context>") || trimmed.starts_with("<INSTRUCTIONS>") || trimmed.starts_with("# AGENTS.md instructions") || trimmed.starts_with("# agents.md instructions") { return None; } Some(trimmed.to_string()) } #[cfg(test)] mod tests { use super::{sanitize_codex_memory_rollout_text, sanitize_codex_query_text}; #[test] fn rollout_sanitizer_drops_agents_and_skills_but_keeps_environment() { let text = concat!( "# AGENTS.md instructions for /tmp\n\n", "<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>\n", "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>\n", "<skill>\n<name>demo</name>\nbody\n</skill>\n", "<subagent_notification>{\"agent_id\":\"a\",\"status\":\"completed\"}</subagent_notification>\n" ); let cleaned = sanitize_codex_memory_rollout_text(text).expect("cleaned"); assert!(!cleaned.contains("AGENTS.md")); assert!(!cleaned.contains("<skill>")); assert!(cleaned.contains("<environment_context>")); assert!(cleaned.contains("<subagent_notification>")); } #[test] fn query_sanitizer_keeps_only_real_user_intent() { let text = concat!( "# AGENTS.md instructions for /tmp\n\n", "<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>\n", "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>\n", "write plan for rollout\n\n", "Workflow context:\n- Repo: ~/code/example\n" ); assert_eq!( sanitize_codex_query_text(text).as_deref(), Some("write plan for rollout") ); } #[test] fn query_sanitizer_drops_context_only_messages() { let text = concat!( "# AGENTS.md instructions for /tmp\n\n", "<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>\n", "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>\n" ); assert_eq!(sanitize_codex_query_text(text), None); } } ================================================ FILE: src/codexd.rs ================================================ use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Write}; #[cfg(unix)] use std::os::fd::AsRawFd; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::{ai, config, daemon, supervisor}; const CODEXD_NAME: &str = "codexd"; #[cfg(unix)] #[derive(Debug)] struct FileLockGuard { fd: std::os::fd::RawFd, } #[cfg(unix)] impl Drop for FileLockGuard { fn drop(&mut self) { let _ = unsafe { libc::flock(self.fd, libc::LOCK_UN) }; } } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum CodexdRequest { Ping, Recent { target_path: String, exact_cwd: bool, limit: usize, query: Option<String>, }, SessionHint { session_hint: String, limit: usize, }, Find { target_path: Option<String>, exact_cwd: bool, query: String, limit: usize, }, } #[derive(Debug, Serialize, Deserialize)] struct CodexdResponse { ok: bool, message: Option<String>, #[serde(default)] rows: Vec<ai::CodexRecoverRow>, } pub fn builtin_daemon_config() -> Result<config::DaemonConfig> { let exe = std::env::current_exe().context("failed to resolve current executable for codexd")?; Ok(config::DaemonConfig { name: CODEXD_NAME.to_string(), binary: exe.display().to_string(), command: Some("codex".to_string()), args: vec![ "daemon".to_string(), "serve".to_string(), "--socket".to_string(), socket_path()?.display().to_string(), ], health_url: None, health_socket: Some(socket_path()?.display().to_string()), port: None, host: None, working_dir: None, env: Default::default(), autostart: false, autostop: false, boot: false, restart: Some(config::DaemonRestartPolicy::Always), retry: None, ready_delay: Some(100), ready_output: None, description: Some("Flow-managed Codex query daemon".to_string()), }) } pub fn socket_path() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()?.join("codexd.sock")) } fn lock_path() -> Result<PathBuf> { Ok(config::ensure_global_state_dir()?.join("codexd.lock")) } #[cfg(unix)] fn acquire_process_lock(file: &std::fs::File) -> Result<FileLockGuard> { let fd = file.as_raw_fd(); let status = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; if status == 0 { return Ok(FileLockGuard { fd }); } let err = std::io::Error::last_os_error(); let raw = err.raw_os_error(); if raw == Some(libc::EWOULDBLOCK) || raw == Some(libc::EAGAIN) { bail!("codexd already holds {}", lock_path()?.display()); } Err(err).context("failed to lock codexd process lock") } #[cfg(not(unix))] fn acquire_process_lock(_file: &std::fs::File) -> Result<()> { Ok(()) } pub fn ping() -> Result<()> { let response = send_request(&CodexdRequest::Ping)?; if response.ok { Ok(()) } else { bail!( "{}", response .message .unwrap_or_else(|| "codexd ping failed".to_string()) ) } } pub fn is_running() -> bool { ping().is_ok() } pub fn ensure_running() -> Result<()> { if is_running() { return Ok(()); } supervisor::ensure_daemon_running(CODEXD_NAME, None, false) } pub fn start() -> Result<()> { supervisor::ensure_daemon_running(CODEXD_NAME, None, true) } pub fn stop() -> Result<()> { supervisor::stop_daemon_managed(CODEXD_NAME, None, true) } pub fn status() -> Result<()> { daemon::show_status_for_with_path(CODEXD_NAME, None) } pub fn serve(socket_override: Option<&Path>) -> Result<()> { let socket = socket_override.map(PathBuf::from).unwrap_or(socket_path()?); if let Some(parent) = socket.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let lock_path = lock_path()?; let mut lock_file = OpenOptions::new() .create(true) .read(true) .write(true) .open(&lock_path) .with_context(|| format!("failed to open {}", lock_path.display()))?; let _process_lock = acquire_process_lock(&lock_file)?; lock_file .set_len(0) .with_context(|| format!("failed to reset {}", lock_path.display()))?; writeln!(lock_file, "{}", std::process::id()) .with_context(|| format!("failed to write {}", lock_path.display()))?; lock_file .flush() .with_context(|| format!("failed to flush {}", lock_path.display()))?; if socket.exists() { fs::remove_file(&socket) .with_context(|| format!("failed to remove stale socket {}", socket.display()))?; } let listener = UnixListener::bind(&socket) .with_context(|| format!("failed to bind codexd socket {}", socket.display()))?; start_background_maintenance_loop(); loop { let (stream, _) = match listener.accept() { Ok(stream) => stream, Err(err) => { eprintln!("WARN codexd accept failed: {err}"); continue; } }; if let Err(err) = handle_client(stream) { eprintln!("WARN codexd request failed: {err:#}"); } } } fn background_poll_secs() -> u64 { std::env::var("FLOW_CODEXD_BACKGROUND_POLL_SECS") .ok() .and_then(|value| value.parse::<u64>().ok()) .map(|value| value.clamp(5, 300)) .unwrap_or(20) } fn start_background_maintenance_loop() { let poll_secs = background_poll_secs(); let _ = thread::Builder::new() .name("flow-codexd-maint".to_string()) .spawn(move || { loop { if let Err(err) = ai::run_codex_background_maintenance() { eprintln!("WARN codexd maintenance failed: {err:#}"); } if let Err(err) = ai::maybe_run_codex_learning_refresh() { eprintln!("WARN codexd learning refresh failed: {err:#}"); } if let Err(err) = ai::maybe_run_codex_telemetry_export(200) { eprintln!("WARN codexd telemetry export failed: {err:#}"); } thread::sleep(Duration::from_secs(poll_secs)); } }); } pub(crate) fn query_recent( target_path: &Path, exact_cwd: bool, limit: usize, query: Option<&str>, ) -> Result<Vec<ai::CodexRecoverRow>> { ensure_running()?; let response = send_request(&CodexdRequest::Recent { target_path: target_path.display().to_string(), exact_cwd, limit, query: query.map(str::to_string), })?; if response.ok { Ok(response.rows) } else { bail!( "{}", response .message .unwrap_or_else(|| "codexd recent query failed".to_string()) ) } } pub(crate) fn query_session_hint( session_hint: &str, limit: usize, ) -> Result<Vec<ai::CodexRecoverRow>> { ensure_running()?; let response = send_request(&CodexdRequest::SessionHint { session_hint: session_hint.to_string(), limit, })?; if response.ok { Ok(response.rows) } else { bail!( "{}", response .message .unwrap_or_else(|| "codexd session hint query failed".to_string()) ) } } pub(crate) fn query_find( target_path: Option<&Path>, exact_cwd: bool, query: &str, limit: usize, ) -> Result<Vec<ai::CodexRecoverRow>> { ensure_running()?; let response = send_request(&CodexdRequest::Find { target_path: target_path.map(|path| path.display().to_string()), exact_cwd, query: query.to_string(), limit, })?; if response.ok { Ok(response.rows) } else { bail!( "{}", response .message .unwrap_or_else(|| "codexd find query failed".to_string()) ) } } fn send_request(request: &CodexdRequest) -> Result<CodexdResponse> { let mut stream = UnixStream::connect(socket_path()?).context("failed to connect to codexd socket")?; let payload = serde_json::to_string(request).context("failed to encode codexd request")?; stream .write_all(payload.as_bytes()) .context("failed to write codexd request")?; stream .write_all(b"\n") .context("failed to terminate codexd request")?; stream.flush().context("failed to flush codexd request")?; let mut reader = BufReader::new(stream); let mut line = Vec::with_capacity(1024); reader .read_until(b'\n', &mut line) .context("failed to read codexd response")?; let trimmed = trim_ascii_whitespace(&line); if trimmed.is_empty() { bail!("codexd returned an empty response"); } serde_json::from_slice(trimmed).context("failed to decode codexd response") } fn handle_client(stream: UnixStream) -> Result<()> { let mut reader = BufReader::new(&stream); let mut line = Vec::with_capacity(1024); reader.read_until(b'\n', &mut line)?; let trimmed = trim_ascii_whitespace(&line); if trimmed.is_empty() { return Ok(()); } let request: CodexdRequest = serde_json::from_slice(trimmed).context("failed to decode codexd request")?; let response = handle_request(request); let mut writer = &stream; let payload = serde_json::to_string(&response).context("failed to encode codexd response")?; writer.write_all(payload.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; Ok(()) } fn handle_request(request: CodexdRequest) -> CodexdResponse { match request { CodexdRequest::Ping => CodexdResponse { ok: true, message: Some("pong".to_string()), rows: vec![], }, CodexdRequest::Recent { target_path, exact_cwd, limit, query, } => match ai::read_recent_codex_threads_local( Path::new(&target_path), exact_cwd, limit, query.as_deref(), ) { Ok(rows) => CodexdResponse { ok: true, message: None, rows, }, Err(err) => CodexdResponse { ok: false, message: Some(format!("{err:#}")), rows: vec![], }, }, CodexdRequest::SessionHint { session_hint, limit, } => match ai::read_codex_threads_by_session_hint_local(&session_hint, limit) { Ok(rows) => CodexdResponse { ok: true, message: None, rows, }, Err(err) => CodexdResponse { ok: false, message: Some(format!("{err:#}")), rows: vec![], }, }, CodexdRequest::Find { target_path, exact_cwd, query, limit, } => match ai::search_codex_threads_for_find_local( target_path.as_deref().map(Path::new), exact_cwd, &query, limit, ) { Ok(rows) => CodexdResponse { ok: true, message: None, rows, }, Err(err) => CodexdResponse { ok: false, message: Some(format!("{err:#}")), rows: vec![], }, }, } } #[inline] fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] { let mut start = 0usize; let mut end = bytes.len(); while start < end && bytes[start].is_ascii_whitespace() { start += 1; } while end > start && bytes[end - 1].is_ascii_whitespace() { end -= 1; } &bytes[start..end] } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn builtin_daemon_config_uses_socket_health() { let cfg = builtin_daemon_config().expect("builtin codexd config"); let socket = socket_path().expect("codexd socket"); assert_eq!(cfg.name, CODEXD_NAME); assert_eq!(cfg.command.as_deref(), Some("codex")); assert_eq!( cfg.effective_health_socket().as_deref(), Some(socket.as_path()) ); let expected_label = format!("unix:{}", socket.display()); assert_eq!( cfg.health_target_label().as_deref(), Some(expected_label.as_str()) ); assert!( cfg.args .windows(2) .any(|window| window == ["daemon", "serve"]) ); } #[cfg(unix)] #[test] fn process_lock_rejects_second_holder() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("codexd.lock"); let first = OpenOptions::new() .create(true) .read(true) .write(true) .open(&path) .expect("first lock file"); let _guard = acquire_process_lock(&first).expect("first lock"); let second = OpenOptions::new() .create(true) .read(true) .write(true) .open(&path) .expect("second lock file"); let err = acquire_process_lock(&second).expect_err("second lock should fail"); assert!(format!("{err:#}").contains("codexd already holds")); } } ================================================ FILE: src/commit.rs ================================================ //! AI-powered git commit command using OpenAI. use std::cell::RefCell; use std::collections::{HashMap, HashSet, hash_map::DefaultHasher}; use std::env; use std::fs; use std::hash::{Hash, Hasher}; use std::io::{self, IsTerminal, Read, Seek, SeekFrom, Write}; use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; use clap::ValueEnum; use flow_commit_scan::scan_diff_for_secrets; use regex::Regex; use reqwest::StatusCode; use reqwest::blocking::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::json; use sha1::{Digest, Sha1}; use tempfile::{Builder as TempBuilder, NamedTempFile, TempDir}; use tracing::{debug, info}; use uuid::Uuid; use crate::ai; use crate::cli::{CommitQueueAction, CommitQueueCommand, DaemonAction, PrOpts}; use crate::config; use crate::daemon; use crate::env as flow_env; use crate::features; use crate::git_guard; use crate::gitignore_policy; use crate::hub; use crate::notify; use crate::setup; use crate::skills; use crate::supervisor; use crate::todo; use crate::undo; use crate::vcs; const MODEL: &str = "gpt-4.1-nano"; const MAX_DIFF_CHARS: usize = 12_000; const HUB_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); const HUB_PORT: u16 = 9050; const DEFAULT_OPENROUTER_REVIEW_MODEL: &str = "arcee-ai/trinity-large-preview:free"; const DEFAULT_OPENCODE_MODEL: &str = "opencode/minimax-m2.1-free"; const DEFAULT_RISE_MODEL: &str = "zai:glm-4.7"; const DEFAULT_GLM5_RISE_MODEL: &str = "zai:glm-5"; /// Patterns for files that likely contain secrets and shouldn't be committed. const SENSITIVE_PATTERNS: &[&str] = &[ ".env", ".env.local", ".env.production", ".env.development", ".env.staging", ".env.host", "credentials.json", "secrets.json", "service-account.json", ".pem", ".key", ".p12", ".pfx", ".keystore", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa", ".npmrc", ".pypirc", ".netrc", "htpasswd", ".htpasswd", "shadow", "passwd", ]; const SYSTEM_PROMPT: &str = "You are an expert software engineer who writes clear, concise git commit messages. Use imperative mood, keep the subject line under 72 characters, and include an optional body with bullet points if helpful. Never wrap the message in quotes. Never include secrets, credentials, or file contents from .env files, environment variables, keys, or other sensitive data—even if they appear in the diff."; #[derive(Copy, Clone, Debug, ValueEnum)] pub enum ReviewModelArg { /// Use Claude Opus 1 for review. ClaudeOpus, /// Use Codex high-capacity review (gpt-5.1-codex-max). CodexHigh, /// Use Codex mini review model (gpt-5.1-codex-mini). CodexMini, } #[derive(Copy, Clone, Debug)] pub struct CommitQueueMode { pub enabled: bool, pub override_flag: Option<bool>, pub open_review: bool, } impl CommitQueueMode { pub fn with_open_review(mut self, open_review: bool) -> Self { self.open_review = open_review; self } } #[derive(Copy, Clone, Debug, Default)] pub struct CommitGateOverrides { pub skip_quality: bool, pub skip_docs: bool, pub skip_tests: bool, } #[derive(Clone, Debug)] struct CommitTestingPolicy { mode: String, runner: String, bun_repo_strict: bool, require_related_tests: bool, ai_scratch_test_dir: String, run_ai_scratch_tests: bool, allow_ai_scratch_to_satisfy_gate: bool, max_local_gate_seconds: u64, } #[derive(Clone, Debug, Default)] struct CommitSkillGatePolicy { mode: String, required: Vec<String>, min_version: HashMap<String, u32>, } #[derive(Clone, Debug, Default)] struct SkillGateReport { pass: bool, mode: String, override_flag: Option<String>, required_skills: Vec<String>, missing_skills: Vec<String>, version_failures: Vec<String>, loaded_versions: HashMap<String, u32>, } impl ReviewModelArg { fn as_arg(&self) -> &'static str { match self { ReviewModelArg::ClaudeOpus => "claude-opus", ReviewModelArg::CodexHigh => "codex-high", ReviewModelArg::CodexMini => "codex-mini", } } } #[derive(Copy, Clone, Debug)] pub enum CodexModel { High, Mini, } impl CodexModel { fn as_codex_arg(&self) -> &'static str { match self { CodexModel::High => "gpt-5.1-codex-max", CodexModel::Mini => "gpt-5.1-codex-mini", } } } #[derive(Copy, Clone, Debug)] pub enum ClaudeModel { Sonnet, Opus, } impl ClaudeModel { fn as_claude_arg(&self) -> &'static str { match self { ClaudeModel::Sonnet => "claude-sonnet-4-20250514", ClaudeModel::Opus => "claude-opus-1", } } } #[derive(Clone, Debug)] pub enum ReviewSelection { Codex(CodexModel), Claude(ClaudeModel), Opencode { model: String }, Rise { model: String }, Kimi { model: Option<String> }, OpenRouter { model: String }, } impl ReviewSelection { fn is_codex(&self) -> bool { matches!(self, ReviewSelection::Codex(_)) } fn is_openrouter(&self) -> bool { matches!(self, ReviewSelection::OpenRouter { .. }) } fn review_model_arg(&self) -> Option<ReviewModelArg> { match self { ReviewSelection::Codex(CodexModel::High) => Some(ReviewModelArg::CodexHigh), ReviewSelection::Codex(CodexModel::Mini) => Some(ReviewModelArg::CodexMini), ReviewSelection::Claude(ClaudeModel::Opus) => Some(ReviewModelArg::ClaudeOpus), ReviewSelection::Claude(ClaudeModel::Sonnet) => None, ReviewSelection::Opencode { .. } => None, ReviewSelection::Rise { .. } => None, ReviewSelection::Kimi { .. } => None, ReviewSelection::OpenRouter { .. } => None, } } fn model_label(&self) -> String { match self { ReviewSelection::Codex(model) => model.as_codex_arg().to_string(), ReviewSelection::Claude(model) => model.as_claude_arg().to_string(), ReviewSelection::Opencode { model } => model.clone(), ReviewSelection::Rise { model } => format!("rise:{}", model), ReviewSelection::Kimi { model } => match model.as_deref() { Some(model) if !model.trim().is_empty() => format!("kimi:{}", model), _ => "kimi".to_string(), }, ReviewSelection::OpenRouter { model } => openrouter_model_label(model), } } } fn review_tool_label(selection: &ReviewSelection) -> &'static str { match selection { ReviewSelection::Claude(_) => "Claude", ReviewSelection::Codex(_) => "Codex", ReviewSelection::Opencode { .. } => "opencode", ReviewSelection::OpenRouter { .. } => "OpenRouter", ReviewSelection::Rise { .. } => "Rise AI", ReviewSelection::Kimi { .. } => "Kimi", } } /// Check staged files for potentially sensitive content and warn the user. /// Returns list of sensitive files found. fn check_sensitive_files(repo_root: &Path) -> Vec<String> { let output = Command::new("git") .args(["diff", "--cached", "--name-only"]) .current_dir(repo_root) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } let files = String::from_utf8_lossy(&output.stdout); let mut sensitive = Vec::new(); for file in files.lines() { let file_lower = file.to_lowercase(); let file_name = Path::new(file) .file_name() .and_then(|n| n.to_str()) .unwrap_or(file) .to_lowercase(); // Check for .env files, but allow .env.example and .env.sample (safe templates) if file_name.starts_with(".env") { if file_name.ends_with(".example") || file_name.ends_with(".sample") { continue; } sensitive.push(file.to_string()); continue; } for pattern in SENSITIVE_PATTERNS { let pattern_lower = pattern.to_lowercase(); // Check if filename matches or ends with pattern if file_name == pattern_lower || file_name.ends_with(&pattern_lower) || file_lower.contains(&format!("/{}", pattern_lower)) { sensitive.push(file.to_string()); break; } } } sensitive } /// Warn about sensitive files and optionally abort. fn warn_sensitive_files(files: &[String]) -> Result<()> { if files.is_empty() { return Ok(()); } if env::var("FLOW_ALLOW_SENSITIVE_COMMIT").ok().as_deref() == Some("1") { return Ok(()); } println!("\n⚠️ Warning: Potentially sensitive files detected:"); for file in files { println!(" - {}", file); } println!(); println!("These files may contain secrets. Consider:"); println!(" - Adding them to .gitignore"); println!(" - Using `git reset HEAD <file>` to unstage"); println!(); bail!("Refusing to commit sensitive files. Set FLOW_ALLOW_SENSITIVE_COMMIT=1 to override.") } /// Warn about secrets found in diff and optionally abort. fn warn_secrets_in_diff( repo_root: &Path, findings: &[(String, usize, String, String)], ) -> Result<()> { if findings.is_empty() { return Ok(()); } if env::var("FLOW_ALLOW_SECRET_COMMIT").ok().as_deref() == Some("1") { println!( "\n⚠️ Warning: Potential secrets detected but FLOW_ALLOW_SECRET_COMMIT=1, continuing..." ); return Ok(()); } println!(); print_secret_findings("🔐 Potential secrets detected in staged changes:", findings); println!(); println!("If these are false positives (examples, placeholders, tests), you can:"); println!(" - Set FLOW_ALLOW_SECRET_COMMIT=1 to override for this commit"); println!( " - Mark the line with '# flow:secret:ignore' (or add it on the line above to ignore the next line)" ); println!(" - Use placeholder values like 'xxx' for example secrets"); println!(" - Re-stage files if you recently edited them: git add <file>"); println!(); let mut unstaged_files: Vec<&str> = Vec::new(); for (file, _, _, _) in findings { if has_unstaged_changes(repo_root, file) { unstaged_files.push(file); } } if !unstaged_files.is_empty() { println!("ℹ️ Staged content differs from working tree for:"); for file in &unstaged_files { println!(" - {}", file); } println!(" Re-run: git add <file> to update the staged diff."); println!(); } let agent_name = env::var("FLOW_FIX_COMMIT_AGENT").unwrap_or_else(|_| "fix-f-commit".to_string()); let agent_enabled = agent_name.trim().to_lowercase() != "off"; let hive_available = which::which("hive").is_ok(); let ai_available = which::which("ai").is_ok(); let interactive = io::stdin().is_terminal(); let mut current_findings = findings.to_vec(); let rescan_after_fix = |findings: &mut Vec<(String, usize, String, String)>| -> Result<()> { git_run_in(repo_root, &["add", "."])?; ensure_no_internal_staged(repo_root)?; ensure_no_unwanted_staged(repo_root)?; gitignore_policy::enforce_staged_policy(repo_root)?; *findings = scan_diff_for_secrets(repo_root); Ok(()) }; if interactive && agent_enabled && hive_available { let task = build_fix_f_commit_task(¤t_findings); println!("Running fix-f-commit agent (hive)..."); if let Err(err) = run_fix_f_commit_agent(repo_root, &agent_name, &task) { eprintln!("⚠ Failed to run fix-f-commit agent: {err}"); eprintln!( " Create the agent at ~/.config/flow/agents/fix-f-commit.md or ~/.hive/agents/fix-f-commit/spec.md" ); eprintln!(); } rescan_after_fix(&mut current_findings)?; if current_findings.is_empty() { if prompt_yes_no_default_yes( "Secret scan is clean after auto-fix. Continue with commit?", )? { return Ok(()); } bail!("Commit aborted after auto-fix. Review changes and retry."); } } else if !agent_enabled { eprintln!("ℹ️ fix-f-commit agent disabled via FLOW_FIX_COMMIT_AGENT=off"); } else if !hive_available { eprintln!("ℹ️ hive not found; skipping fix-f-commit agent"); } if interactive && !current_findings.is_empty() && ai_available { if prompt_yes_no_default_yes("Run auto-fix with ai?")? { let task = build_fix_f_commit_task(¤t_findings); println!("Running auto-fix with ai..."); if let Err(err) = run_fix_f_commit_ai(repo_root, &task) { eprintln!("⚠ Failed to run ai auto-fix: {err}"); } rescan_after_fix(&mut current_findings)?; if current_findings.is_empty() { if prompt_yes_no_default_yes( "Secret scan is clean after auto-fix. Continue with commit?", )? { return Ok(()); } bail!("Commit aborted after auto-fix. Review changes and retry."); } } } if current_findings != findings { print_secret_findings( "🔐 Potential secrets still detected in staged changes:", ¤t_findings, ); println!(); } let task = build_fix_f_commit_task(¤t_findings); if !task.trim().is_empty() { eprintln!("Suggested prompt (copy/paste into your model):"); eprintln!("────────────────────────────────────────"); eprintln!("{}", task); eprintln!("────────────────────────────────────────"); } bail!("Refusing to commit potential secrets. Review the findings above.") } fn should_run_sync_for_secret_fixes(repo_root: &Path) -> Result<bool> { if !io::stdin().is_terminal() { return Ok(false); } if env::var("FLOW_ALLOW_SECRET_COMMIT").ok().as_deref() == Some("1") { return Ok(false); } let agent_name = env::var("FLOW_FIX_COMMIT_AGENT").unwrap_or_else(|_| "fix-f-commit".to_string()); let hive_enabled = agent_name.trim().to_lowercase() != "off" && which::which("hive").is_ok(); let ai_available = which::which("ai").is_ok(); if !hive_enabled && !ai_available { return Ok(false); } git_run(&["add", "."])?; ensure_no_internal_staged(repo_root)?; ensure_no_unwanted_staged(repo_root)?; gitignore_policy::enforce_staged_policy(repo_root)?; Ok(!scan_diff_for_secrets(repo_root).is_empty()) } fn run_fix_f_commit_agent(repo_root: &Path, agent: &str, task: &str) -> Result<()> { if which::which("hive").is_err() { bail!("hive not found in PATH"); } let mut cmd = Command::new("hive"); cmd.args(["agent", &agent, task]) .current_dir(repo_root) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .envs(resolve_hive_env()); let status = cmd.status().context("failed to run hive agent")?; if !status.success() { bail!("hive agent '{}' failed", agent); } Ok(()) } fn run_fix_f_commit_ai(repo_root: &Path, task: &str) -> Result<()> { if which::which("ai").is_err() { bail!("ai not found in PATH"); } let status = Command::new("ai") .args(["--prompt", task]) .current_dir(repo_root) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run ai")?; if !status.success() { bail!("ai auto-fix failed"); } Ok(()) } fn build_fix_f_commit_task(findings: &[(String, usize, String, String)]) -> String { let mut summary = String::new(); for (file, line, pattern, matched) in findings { summary.push_str(&format!( "- {}:{} — {} ({})\n", file, line, pattern, matched )); } let task = format!( "Fix f commit secret detection.\n\n\ Findings:\n{summary}\n\ Please remove or mask real secrets, replace with placeholders if needed, \ and update .gitignore or docs/examples so the commit passes the secret scan. \ If the match is a false positive, prefer marking the flagged line with `flow:secret:ignore` (for example: `# flow:secret:ignore`). \ If you must keep the pattern but want it to pass the scanner, use 'xxx' placeholders.\n\ After fixing, restage changes." ); sanitize_hive_task(&task) } fn print_secret_findings(header: &str, findings: &[(String, usize, String, String)]) { println!("{}", header); for (file, line, pattern, matched) in findings { println!(" {}:{} - {} ({})", file, line, pattern, matched); } } fn has_unstaged_changes(repo_root: &Path, file: &str) -> bool { let output = Command::new("git") .args(["diff", "--name-only", "--", file]) .current_dir(repo_root) .output(); let Ok(output) = output else { return false; }; if !output.status.success() { return false; } let output = String::from_utf8_lossy(&output.stdout); output.lines().any(|line| line.trim() == file) } fn sanitize_hive_task(task: &str) -> String { let mut cleaned = String::with_capacity(task.len()); for ch in task.chars() { match ch { '"' => cleaned.push('\''), '\n' | '\r' | '\t' => cleaned.push(' '), _ => cleaned.push(ch), } } cleaned } fn resolve_hive_env() -> Vec<(String, String)> { let mut vars = Vec::new(); if std::env::var("CEREBRAS_API_KEY") .map(|v| v.trim().is_empty()) .unwrap_or(true) { if is_local_env_backend() { if let Ok(store) = crate::env::fetch_personal_env_vars(&["CEREBRAS_API_KEY".to_string()]) { if let Some(value) = store.get("CEREBRAS_API_KEY") { if !value.trim().is_empty() { vars.push(("CEREBRAS_API_KEY".to_string(), value.to_string())); } } } } } vars } /// Threshold for "large" file changes (lines added + removed). const LARGE_DIFF_THRESHOLD: usize = 500; /// Check for files with unusually large diffs. /// Returns list of (filename, lines_changed) for files over threshold. fn check_large_diffs(repo_root: &Path) -> Vec<(String, usize)> { let output = Command::new("git") .args(["diff", "--cached", "--numstat"]) .current_dir(repo_root) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } let stats = String::from_utf8_lossy(&output.stdout); let mut large_files = Vec::new(); for line in stats.lines() { let parts: Vec<&str> = line.split('\t').collect(); if parts.len() >= 3 { // Format: added<tab>removed<tab>filename // Binary files show "-" for added/removed let added: usize = parts[0].parse().unwrap_or(0); let removed: usize = parts[1].parse().unwrap_or(0); let filename = parts[2].to_string(); let total = added + removed; if total >= LARGE_DIFF_THRESHOLD { large_files.push((filename, total)); } } } // Sort by size descending large_files.sort_by(|a, b| b.1.cmp(&a.1)); large_files } /// Warn about files with large diffs. fn warn_large_diffs(files: &[(String, usize)]) -> Result<()> { if files.is_empty() { return Ok(()); } println!( "⚠️ Warning: Files with large diffs ({}+ lines):", LARGE_DIFF_THRESHOLD ); for (file, lines) in files { println!(" - {} ({} lines)", file, lines); } println!(); println!("These might be generated/lock files. Consider:"); println!(" - Adding them to .gitignore if generated"); println!(" - Using `git reset HEAD <file>` to unstage"); println!(); Ok(()) } /// Check TypeScript config for review settings first, then fall back to commit settings. pub fn resolve_review_selection_from_config() -> Option<ReviewSelection> { let ts_config = config::load_ts_config()?; let flow_config = ts_config.flow?; // Check review config first (takes precedence) let (tool, model) = if let Some(ref review_config) = flow_config.review { if let Some(ref tool) = review_config.tool { (tool.as_str(), review_config.model.clone()) } else if let Some(ref commit_config) = flow_config.commit { // Fall back to commit config (commit_config.tool.as_deref()?, commit_config.model.clone()) } else { return None; } } else if let Some(ref commit_config) = flow_config.commit { // No review config, use commit config (commit_config.tool.as_deref()?, commit_config.model.clone()) } else { return None; }; match tool { "opencode" => { let model = model.unwrap_or_else(|| DEFAULT_OPENCODE_MODEL.to_string()); Some(ReviewSelection::Opencode { model }) } "openrouter" => { let model = model.unwrap_or_else(|| DEFAULT_OPENROUTER_REVIEW_MODEL.to_string()); Some(ReviewSelection::OpenRouter { model }) } "rise" => { let model = model.unwrap_or_else(|| DEFAULT_RISE_MODEL.to_string()); Some(ReviewSelection::Rise { model }) } "glm5" | "glm-5" | "glm" => { let model = model.unwrap_or_else(|| DEFAULT_GLM5_RISE_MODEL.to_string()); Some(ReviewSelection::Rise { model }) } "kimi" => Some(ReviewSelection::Kimi { model }), "claude" => { let model_enum = match model.as_deref() { Some("opus") | Some("claude-opus") => ClaudeModel::Opus, _ => ClaudeModel::Sonnet, }; Some(ReviewSelection::Claude(model_enum)) } "codex" => { let model_enum = match model.as_deref() { Some("mini") | Some("codex-mini") => CodexModel::Mini, _ => CodexModel::High, }; Some(ReviewSelection::Codex(model_enum)) } _ => None, } } pub fn resolve_review_selection( use_claude: bool, override_model: Option<ReviewModelArg>, ) -> ReviewSelection { // Check TypeScript config first if let Some(selection) = resolve_review_selection_from_config() { return selection; } if let Some(model) = override_model { return match model { ReviewModelArg::ClaudeOpus => ReviewSelection::Claude(ClaudeModel::Opus), ReviewModelArg::CodexHigh => ReviewSelection::Codex(CodexModel::High), ReviewModelArg::CodexMini => ReviewSelection::Codex(CodexModel::Mini), }; } if use_claude { ReviewSelection::Claude(ClaudeModel::Sonnet) } else { ReviewSelection::Codex(CodexModel::High) } } /// New default: Claude is default, --codex flag to use Codex pub fn resolve_review_selection_v2( use_codex: bool, override_model: Option<ReviewModelArg>, ) -> ReviewSelection { // Check TypeScript config first if let Some(selection) = resolve_review_selection_from_config() { return selection; } if let Some(model) = override_model { return match model { ReviewModelArg::ClaudeOpus => ReviewSelection::Claude(ClaudeModel::Opus), ReviewModelArg::CodexHigh => ReviewSelection::Codex(CodexModel::High), ReviewModelArg::CodexMini => ReviewSelection::Codex(CodexModel::Mini), }; } if use_codex { ReviewSelection::Codex(CodexModel::High) } else { // Default: Claude Sonnet ReviewSelection::Claude(ClaudeModel::Sonnet) } } fn parse_boolish(value: &str) -> Option<bool> { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, } } fn load_ts_commit_config() -> Option<config::TsCommitConfig> { config::load_ts_config() .and_then(|cfg| cfg.flow) .and_then(|flow| flow.commit) } fn load_local_commit_config(repo_root: &Path) -> Option<config::CommitConfig> { let local = repo_root.join("flow.toml"); if !local.exists() { return None; } config::load(&local).ok().and_then(|cfg| cfg.commit) } fn load_global_commit_config() -> Option<config::CommitConfig> { let global = config::default_config_path(); if !global.exists() { return None; } config::load(&global).ok().and_then(|cfg| cfg.commit) } pub fn commit_quick_default_enabled() -> bool { if let Ok(value) = env::var("FLOW_COMMIT_QUICK_DEFAULT") { if let Some(parsed) = parse_boolish(&value) { return parsed; } } if let Some(ts) = load_ts_commit_config() { if let Some(enabled) = ts.quick_default { return enabled; } } let repo_root = git_root_or_cwd(); if let Some(local) = load_local_commit_config(&repo_root) { if let Some(enabled) = local.quick_default { return enabled; } } if let Some(global) = load_global_commit_config() { if let Some(enabled) = global.quick_default { return enabled; } } true } fn commit_review_fail_open_enabled(repo_root: &Path) -> bool { if let Ok(value) = env::var("FLOW_COMMIT_REVIEW_FAIL_OPEN") { if let Some(parsed) = parse_boolish(&value) { return parsed; } } if let Some(ts) = load_ts_commit_config() { if let Some(enabled) = ts.review_fail_open { return enabled; } } if let Some(local) = load_local_commit_config(repo_root) { if let Some(enabled) = local.review_fail_open { return enabled; } } if let Some(global) = load_global_commit_config() { if let Some(enabled) = global.review_fail_open { return enabled; } } true } fn commit_message_fail_open_enabled(repo_root: &Path) -> bool { if let Ok(value) = env::var("FLOW_COMMIT_MESSAGE_FAIL_OPEN") { if let Some(parsed) = parse_boolish(&value) { return parsed; } } if let Some(ts) = load_ts_commit_config() { if let Some(enabled) = ts.message_fail_open { return enabled; } } if let Some(local) = load_local_commit_config(repo_root) { if let Some(enabled) = local.message_fail_open { return enabled; } } if let Some(global) = load_global_commit_config() { if let Some(enabled) = global.message_fail_open { return enabled; } } true } fn parse_review_selection_spec(spec: &str) -> Option<ReviewSelection> { let trimmed = spec.trim(); if trimmed.is_empty() { return None; } let lower = trimmed.to_ascii_lowercase(); if lower == "codex" || lower == "codex-high" { return Some(ReviewSelection::Codex(CodexModel::High)); } if lower == "codex-mini" || lower == "codex:mini" || lower == "codex-mini-review" { return Some(ReviewSelection::Codex(CodexModel::Mini)); } if lower == "claude" || lower == "claude-sonnet" { return Some(ReviewSelection::Claude(ClaudeModel::Sonnet)); } if lower == "claude-opus" || lower == "claude:opus" { return Some(ReviewSelection::Claude(ClaudeModel::Opus)); } if lower == "kimi" { return Some(ReviewSelection::Kimi { model: None }); } if let Some(model) = trimmed .strip_prefix("openrouter:") .or_else(|| trimmed.strip_prefix("openrouter/")) { let model = if model.trim().is_empty() { DEFAULT_OPENROUTER_REVIEW_MODEL.to_string() } else { model.trim().to_string() }; return Some(ReviewSelection::OpenRouter { model }); } if lower == "openrouter" { return Some(ReviewSelection::OpenRouter { model: DEFAULT_OPENROUTER_REVIEW_MODEL.to_string(), }); } if let Some(model) = trimmed .strip_prefix("rise:") .or_else(|| trimmed.strip_prefix("rise/")) { let model = if model.trim().is_empty() { DEFAULT_RISE_MODEL.to_string() } else { model.trim().to_string() }; return Some(ReviewSelection::Rise { model }); } if lower == "rise" { return Some(ReviewSelection::Rise { model: DEFAULT_RISE_MODEL.to_string(), }); } if lower == "glm5" || lower == "glm-5" || lower == "glm" { return Some(ReviewSelection::Rise { model: DEFAULT_GLM5_RISE_MODEL.to_string(), }); } if let Some(model) = trimmed .strip_prefix("glm5:") .or_else(|| trimmed.strip_prefix("glm5/")) .or_else(|| trimmed.strip_prefix("glm-5:")) .or_else(|| trimmed.strip_prefix("glm-5/")) { let model = if model.trim().is_empty() { DEFAULT_GLM5_RISE_MODEL.to_string() } else { model.trim().to_string() }; return Some(ReviewSelection::Rise { model }); } if let Some(model) = trimmed .strip_prefix("opencode:") .or_else(|| trimmed.strip_prefix("opencode/")) { let model = if model.trim().is_empty() { DEFAULT_OPENCODE_MODEL.to_string() } else { model.trim().to_string() }; return Some(ReviewSelection::Opencode { model }); } if lower == "opencode" { return Some(ReviewSelection::Opencode { model: DEFAULT_OPENCODE_MODEL.to_string(), }); } None } fn commit_review_fallback_specs(repo_root: &Path) -> Vec<String> { if let Ok(raw) = env::var("FLOW_COMMIT_REVIEW_FALLBACKS") { let parsed = raw .split([',', '\n']) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } if let Some(ts) = load_ts_commit_config() { if let Some(v) = ts.review_fallbacks { let parsed = v .into_iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } } if let Some(local) = load_local_commit_config(repo_root) { if let Some(v) = local.review_fallbacks { let parsed = v .into_iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } } if let Some(global) = load_global_commit_config() { if let Some(v) = global.review_fallbacks { let parsed = v .into_iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } } vec![ "openrouter".to_string(), "claude".to_string(), "codex-high".to_string(), ] } fn review_attempts_for_selection( repo_root: &Path, primary: &ReviewSelection, prefer_codex_over_openrouter: bool, ) -> Vec<ReviewSelection> { let mut attempts: Vec<ReviewSelection> = Vec::new(); if prefer_codex_over_openrouter { attempts.push(ReviewSelection::Codex(CodexModel::High)); } attempts.push(primary.clone()); for spec in commit_review_fallback_specs(repo_root) { if let Some(selection) = parse_review_selection_spec(&spec) { attempts.push(selection); } } let mut seen = HashSet::new(); let mut deduped = Vec::new(); for attempt in attempts { let key = attempt.model_label(); if seen.insert(key) { deduped.push(attempt); } } deduped } #[derive(Debug, Clone)] enum CommitMessageSelection { Kimi { model: Option<String> }, Claude, Opencode { model: String }, OpenRouter { model: String }, Rise { model: String }, Remote, OpenAi, Heuristic, } impl CommitMessageSelection { fn key(&self) -> String { match self { CommitMessageSelection::Kimi { model } => match model.as_deref() { Some(model) if !model.trim().is_empty() => format!("kimi:{}", model.trim()), _ => "kimi".to_string(), }, CommitMessageSelection::Claude => "claude".to_string(), CommitMessageSelection::Opencode { model } => format!("opencode:{}", model.trim()), CommitMessageSelection::OpenRouter { model } => { format!("openrouter:{}", openrouter_model_id(model)) } CommitMessageSelection::Rise { model } => format!("rise:{}", model.trim()), CommitMessageSelection::Remote => "remote".to_string(), CommitMessageSelection::OpenAi => "openai".to_string(), CommitMessageSelection::Heuristic => "heuristic".to_string(), } } fn label(&self) -> String { match self { CommitMessageSelection::Kimi { .. } => "Kimi".to_string(), CommitMessageSelection::Claude => "Claude".to_string(), CommitMessageSelection::Opencode { .. } => "opencode".to_string(), CommitMessageSelection::OpenRouter { .. } => "OpenRouter".to_string(), CommitMessageSelection::Rise { .. } => "Rise".to_string(), CommitMessageSelection::Remote => "myflow".to_string(), CommitMessageSelection::OpenAi => "OpenAI".to_string(), CommitMessageSelection::Heuristic => "deterministic fallback".to_string(), } } } fn parse_commit_message_selection_spec(spec: &str) -> Option<CommitMessageSelection> { let trimmed = spec.trim(); if trimmed.is_empty() { return None; } let lower = trimmed.to_ascii_lowercase(); if lower == "remote" || lower == "myflow" || lower == "flow" { return Some(CommitMessageSelection::Remote); } if lower == "openai" { return Some(CommitMessageSelection::OpenAi); } if lower == "heuristic" || lower == "fallback" || lower == "local" { return Some(CommitMessageSelection::Heuristic); } if lower == "claude" { return Some(CommitMessageSelection::Claude); } if lower == "kimi" { return Some(CommitMessageSelection::Kimi { model: None }); } if let Some(model) = trimmed .strip_prefix("kimi:") .or_else(|| trimmed.strip_prefix("kimi/")) { let model = model.trim(); return Some(CommitMessageSelection::Kimi { model: if model.is_empty() { None } else { Some(model.to_string()) }, }); } if let Some(model) = trimmed .strip_prefix("openrouter:") .or_else(|| trimmed.strip_prefix("openrouter/")) { let model = if model.trim().is_empty() { DEFAULT_OPENROUTER_REVIEW_MODEL.to_string() } else { model.trim().to_string() }; return Some(CommitMessageSelection::OpenRouter { model }); } if lower == "openrouter" { return Some(CommitMessageSelection::OpenRouter { model: DEFAULT_OPENROUTER_REVIEW_MODEL.to_string(), }); } if let Some(model) = trimmed .strip_prefix("opencode:") .or_else(|| trimmed.strip_prefix("opencode/")) { let model = if model.trim().is_empty() { DEFAULT_OPENCODE_MODEL.to_string() } else { model.trim().to_string() }; return Some(CommitMessageSelection::Opencode { model }); } if lower == "opencode" { return Some(CommitMessageSelection::Opencode { model: DEFAULT_OPENCODE_MODEL.to_string(), }); } if let Some(model) = trimmed .strip_prefix("rise:") .or_else(|| trimmed.strip_prefix("rise/")) { let model = if model.trim().is_empty() { DEFAULT_RISE_MODEL.to_string() } else { model.trim().to_string() }; return Some(CommitMessageSelection::Rise { model }); } if lower == "rise" { return Some(CommitMessageSelection::Rise { model: DEFAULT_RISE_MODEL.to_string(), }); } if lower == "glm5" || lower == "glm-5" || lower == "glm" { return Some(CommitMessageSelection::Rise { model: DEFAULT_GLM5_RISE_MODEL.to_string(), }); } if let Some(model) = trimmed .strip_prefix("glm5:") .or_else(|| trimmed.strip_prefix("glm5/")) .or_else(|| trimmed.strip_prefix("glm-5:")) .or_else(|| trimmed.strip_prefix("glm-5/")) { let model = if model.trim().is_empty() { DEFAULT_GLM5_RISE_MODEL.to_string() } else { model.trim().to_string() }; return Some(CommitMessageSelection::Rise { model }); } None } fn parse_commit_message_selection_with_model( tool: &str, model: Option<String>, ) -> Option<CommitMessageSelection> { let tool_trimmed = tool.trim(); if tool_trimmed.is_empty() { return None; } let model_trimmed = model.and_then(|m| { let trimmed = m.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); match tool_trimmed.to_ascii_lowercase().as_str() { "kimi" => Some(CommitMessageSelection::Kimi { model: model_trimmed, }), "claude" => Some(CommitMessageSelection::Claude), "openrouter" => Some(CommitMessageSelection::OpenRouter { model: model_trimmed.unwrap_or_else(|| DEFAULT_OPENROUTER_REVIEW_MODEL.to_string()), }), "opencode" => Some(CommitMessageSelection::Opencode { model: model_trimmed.unwrap_or_else(|| DEFAULT_OPENCODE_MODEL.to_string()), }), "rise" => Some(CommitMessageSelection::Rise { model: model_trimmed.unwrap_or_else(|| DEFAULT_RISE_MODEL.to_string()), }), "glm5" | "glm-5" | "glm" => Some(CommitMessageSelection::Rise { model: model_trimmed.unwrap_or_else(|| DEFAULT_GLM5_RISE_MODEL.to_string()), }), "remote" | "myflow" | "flow" => Some(CommitMessageSelection::Remote), "openai" => Some(CommitMessageSelection::OpenAi), "heuristic" | "fallback" | "local" => Some(CommitMessageSelection::Heuristic), _ => parse_commit_message_selection_spec(tool_trimmed), } } fn commit_message_fallback_specs(repo_root: &Path) -> Vec<String> { if let Ok(raw) = env::var("FLOW_COMMIT_MESSAGE_FALLBACKS") { let parsed = raw .split([',', '\n']) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } if let Some(ts) = load_ts_commit_config() { if let Some(v) = ts.message_fallbacks { let parsed = v .into_iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } } if let Some(local) = load_local_commit_config(repo_root) { if let Some(v) = local.message_fallbacks { let parsed = v .into_iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } } if let Some(global) = load_global_commit_config() { if let Some(v) = global.message_fallbacks { let parsed = v .into_iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::<Vec<_>>(); if !parsed.is_empty() { return parsed; } } } vec![ "remote".to_string(), "openai".to_string(), "openrouter".to_string(), ] } fn review_selection_to_message_selection( review_selection: &ReviewSelection, ) -> Option<CommitMessageSelection> { match review_selection { ReviewSelection::Claude(_) => Some(CommitMessageSelection::Claude), ReviewSelection::Opencode { model } => Some(CommitMessageSelection::Opencode { model: model.clone(), }), ReviewSelection::OpenRouter { model } => Some(CommitMessageSelection::OpenRouter { model: model.clone(), }), ReviewSelection::Rise { model } => Some(CommitMessageSelection::Rise { model: model.clone(), }), ReviewSelection::Kimi { model } => Some(CommitMessageSelection::Kimi { model: model.clone(), }), ReviewSelection::Codex(_) => None, } } fn commit_message_attempts( repo_root: &Path, review_selection: Option<&ReviewSelection>, override_selection: Option<&CommitMessageSelection>, ) -> Vec<CommitMessageSelection> { let mut attempts: Vec<CommitMessageSelection> = Vec::new(); if let Some(selection) = override_selection { attempts.push(selection.clone()); } else if let Some(review_selection) = review_selection { if let Some(selection) = review_selection_to_message_selection(review_selection) { attempts.push(selection); } } for spec in commit_message_fallback_specs(repo_root) { if let Some(selection) = parse_commit_message_selection_spec(&spec) { attempts.push(selection); } } let mut seen = HashSet::new(); let mut deduped = Vec::new(); for attempt in attempts { let key = attempt.key(); if seen.insert(key) { deduped.push(attempt); } } deduped } #[derive(Debug, Deserialize)] struct ReviewJson { issues_found: bool, #[serde(default)] issues: Vec<String>, #[serde(default)] summary: Option<String>, #[serde(default)] future_tasks: Vec<String>, #[serde(default)] quality: Option<QualityResult>, } #[derive(Debug, Serialize)] struct RemoteReviewRequest { diff: String, context: Option<String>, model: String, #[serde(skip_serializing_if = "Option::is_none")] review_instructions: Option<String>, } #[derive(Debug, Deserialize)] struct RemoteReviewResponse { output: String, #[serde(default)] stderr: String, } #[derive(Debug, Deserialize)] struct RemoteCommitMessageResponse { message: String, } #[derive(Debug)] struct ReviewResult { issues_found: bool, issues: Vec<String>, summary: Option<String>, future_tasks: Vec<String>, timed_out: bool, quality: Option<QualityResult>, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub(crate) struct QualityResult { pub(crate) features_touched: Vec<FeatureTouched>, pub(crate) new_features: Vec<NewFeature>, pub(crate) test_coverage: String, pub(crate) doc_coverage: String, pub(crate) gate_pass: bool, pub(crate) gate_failures: Vec<String>, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub(crate) struct FeatureTouched { pub(crate) name: String, pub(crate) action: String, pub(crate) description: String, pub(crate) files_changed: Vec<String>, pub(crate) has_tests: bool, pub(crate) test_files: Vec<String>, pub(crate) doc_current: bool, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub(crate) struct NewFeature { pub(crate) name: String, pub(crate) description: String, pub(crate) files: Vec<String>, pub(crate) doc_content: String, } #[derive(Debug)] struct StagedSnapshot { patch_path: Option<std::path::PathBuf>, } #[derive(Debug, Serialize)] struct UnhashCommitMetadata { repo: String, repo_root: String, branch: String, created_at: String, commit_message: String, author_message: Option<String>, include_context: bool, context_chars: Option<usize>, review_model: Option<String>, review_instructions: Option<String>, review_issues: Vec<String>, review_summary: Option<String>, review_future_tasks: Vec<String>, review_timed_out: bool, gitedit_session_hash: Option<String>, session_count: usize, } #[derive(Debug, Serialize)] struct UnhashReviewPayload { issues_found: bool, issues: Vec<String>, summary: Option<String>, future_tasks: Vec<String>, timed_out: bool, model: Option<String>, reviewer: Option<String>, } #[derive(Debug, Serialize)] struct ChatRequest { model: String, messages: Vec<Message>, temperature: f32, } #[derive(Debug, Serialize)] struct Message { role: String, content: String, } #[derive(Debug, Deserialize)] struct ChatResponse { choices: Vec<Choice>, } #[derive(Debug, Deserialize)] struct Choice { message: Option<ResponseMessage>, } #[derive(Debug, Deserialize)] struct ResponseMessage { content: String, } fn parse_rise_output(text: &str) -> Result<String> { let trimmed = text.trim(); if trimmed.is_empty() { bail!("Rise daemon returned empty response"); } if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) { if let Some(err) = value.get("error") { let code = err .get("code") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let message = err .get("message") .and_then(|v| v.as_str()) .unwrap_or("unknown error"); bail!("Rise daemon error: {} ({})", message, code); } } if let Ok(response) = serde_json::from_str::<ChatResponse>(text) { if let Some(output) = response .choices .first() .and_then(|c| c.message.as_ref()) .map(|m| m.content.clone()) { if !output.trim().is_empty() { return Ok(output); } } } if let Ok(value) = serde_json::from_str::<serde_json::Value>(text) { if let Some(content) = value .get("assistant") .and_then(|v| v.as_str()) .map(|s| s.to_string()) { if !content.trim().is_empty() { return Ok(content); } } if let Some(content) = value .pointer("/choices/0/message/content") .and_then(|v| v.as_str()) .map(|s| s.to_string()) { if !content.trim().is_empty() { return Ok(content); } } } Ok(trimmed.to_string()) } fn is_rise_auth_error(text: &str) -> bool { let trimmed = text.trim(); trimmed.contains("Authorization Token Missing") || trimmed.contains("\"code\":\"1001\"") || trimmed.contains("\"code\":1001") } fn rise_provider_from_model(model: &str) -> Option<String> { let trimmed = model.trim(); if trimmed.is_empty() { return None; } let stripped = trimmed.strip_prefix("rise:").unwrap_or(trimmed); let provider = stripped.split(':').next().unwrap_or("").trim(); if provider.is_empty() { return None; } Some(provider.to_ascii_lowercase()) } fn rise_provider_env_key(provider: &str) -> Option<&'static str> { match provider { "zai" => Some("ZAI_API_KEY"), "xai" => Some("XAI_API_KEY"), "cerebras" => Some("CEREBRAS_API_KEY"), "deepseek" => Some("DEEPSEEK_API_KEY"), "openai" => Some("OPENAI_API_KEY"), _ => None, } } fn is_local_env_backend() -> bool { if let Some(backend) = crate::config::preferred_env_backend() { return backend.eq_ignore_ascii_case("local"); } match std::env::var("FLOW_ENV_BACKEND") .ok() .map(|v| v.to_ascii_lowercase()) .as_deref() { Some("local") => true, Some("cloud") | Some("remote") => false, _ => std::env::var("FLOW_ENV_LOCAL") .ok() .map(|v| { let v = v.to_ascii_lowercase(); v == "1" || v == "true" || v == "yes" }) .unwrap_or(false), } } fn rise_auth_error_message(model: &str) -> String { let Some(provider) = rise_provider_from_model(model) else { return "Rise daemon error: Authorization Token Missing (1001).".to_string(); }; let Some(env_key) = rise_provider_env_key(&provider) else { return format!( "Rise daemon error: Authorization Token Missing (1001). Missing auth for provider '{}'.", provider ); }; let has_env = std::env::var(env_key) .map(|v| !v.trim().is_empty()) .unwrap_or(false); let has_store = if is_local_env_backend() { crate::env::fetch_personal_env_vars(&[env_key.to_string()]) .ok() .and_then(|vars| vars.get(env_key).cloned()) .map(|v| !v.trim().is_empty()) .unwrap_or(false) } else { false }; let mut message = format!( "Rise daemon error: Authorization Token Missing (1001). Missing {} for provider '{}'.", env_key, provider ); if has_store || has_env { message.push_str(" Restart the Rise daemon so it picks up the key."); } else { message.push_str(&format!( " Set it in Flow env store: f env set --personal {}=... then restart the Rise daemon.", env_key )); } message } fn rise_url() -> String { std::env::var("ZERG_AI_URL") .or_else(|_| std::env::var("FLOW_RISE_URL")) .or_else(|_| std::env::var("RISE_URL")) .unwrap_or_else(|_| "http://localhost:7654/v1/chat/completions".to_string()) } fn rise_health_url(rise_url: &str) -> Option<String> { let trimmed = rise_url.trim_end_matches('/'); let idx = trimmed.find("/v1/")?; Some(format!("{}/health", &trimmed[..idx])) } fn wait_for_rise_ready(client: &Client, rise_url: &str) { let Some(health_url) = rise_health_url(rise_url) else { return; }; for _ in 0..12 { match client.get(&health_url).send() { Ok(resp) if resp.status().is_success() => return, _ => std::thread::sleep(Duration::from_millis(350)), } } } fn try_start_rise_daemon() -> Result<()> { let action = DaemonAction::Start { name: "rise".to_string(), }; if supervisor::try_handle_daemon_action(&action, None)? { return Ok(()); } daemon::start_daemon_with_path("rise", None) } fn try_restart_rise_daemon() -> Result<()> { let action = DaemonAction::Restart { name: "rise".to_string(), }; if supervisor::try_handle_daemon_action(&action, None)? { return Ok(()); } daemon::stop_daemon_with_path("rise", None).ok(); daemon::start_daemon_with_path("rise", None) } fn send_rise_request_text( client: &Client, rise_url: &str, body: &ChatRequest, model: &str, ) -> Result<String> { let resp = send_rise_request(client, rise_url, body)?; if !resp.status().is_success() { let error_text = resp.text().unwrap_or_else(|_| "unknown error".to_string()); if is_rise_auth_error(&error_text) { info!("Rise auth error; attempting daemon restart..."); if let Err(err) = try_restart_rise_daemon() { bail!( "{} (restart failed: {})", rise_auth_error_message(model), err ); } std::thread::sleep(Duration::from_millis(500)); wait_for_rise_ready(client, rise_url); let resp = send_rise_request(client, rise_url, body)?; if !resp.status().is_success() { let error_text = resp.text().unwrap_or_else(|_| "unknown error".to_string()); bail!("Rise daemon error: {}", error_text); } let text = resp.text().context("failed to read Rise response")?; if is_rise_auth_error(&text) { bail!(rise_auth_error_message(model)); } return Ok(text); } bail!("Rise daemon error: {}", error_text); } let text = resp.text().context("failed to read Rise response")?; if is_rise_auth_error(&text) { info!("Rise auth error; attempting daemon restart..."); if let Err(err) = try_restart_rise_daemon() { bail!( "{} (restart failed: {})", rise_auth_error_message(model), err ); } std::thread::sleep(Duration::from_millis(500)); wait_for_rise_ready(client, rise_url); let resp = send_rise_request(client, rise_url, body)?; if !resp.status().is_success() { let error_text = resp.text().unwrap_or_else(|_| "unknown error".to_string()); bail!("Rise daemon error: {}", error_text); } let text = resp.text().context("failed to read Rise response")?; if is_rise_auth_error(&text) { bail!(rise_auth_error_message(model)); } return Ok(text); } Ok(text) } fn send_rise_request( client: &Client, rise_url: &str, body: &ChatRequest, ) -> Result<reqwest::blocking::Response> { match client.post(rise_url).json(body).send() { Ok(resp) => Ok(resp), Err(err) => { if err.is_connect() { info!("Rise daemon unreachable; attempting auto-start..."); if let Err(start_err) = try_start_rise_daemon() { return Err(err).with_context(|| { format!( "failed to reach Rise daemon at {}. Auto-start failed: {}", rise_url, start_err ) }); } std::thread::sleep(Duration::from_millis(500)); wait_for_rise_ready(client, rise_url); return client.post(rise_url).json(body).send().with_context(|| { format!( "failed to reach Rise daemon at {} after auto-start. Start with: f rise", rise_url ) }); } Err(err).with_context(|| { format!( "failed to reach Rise daemon at {}. Start with: f rise", rise_url ) }) } } } /// Dry run: show the context that would be passed to Codex without committing. pub fn dry_run_context() -> Result<()> { println!("Dry run: showing context that would be passed to Codex\n"); // Ensure we're in a git repo ensure_git_repo()?; // Show checkpoint info let cwd = std::env::current_dir()?; let checkpoints = ai::load_checkpoints(&cwd).unwrap_or_default(); println!("────────────────────────────────────────"); println!("COMMIT CHECKPOINT"); println!("────────────────────────────────────────"); if let Some(ref checkpoint) = checkpoints.last_commit { println!("Last commit: {}", checkpoint.timestamp); if let Some(ref ts) = checkpoint.last_entry_timestamp { println!("Last entry included: {}", ts); } if let Some(ref sid) = checkpoint.session_id { println!("Session: {}...", &sid[..8.min(sid.len())]); } } else { println!("No previous checkpoint (first commit with context)"); } // Get diff let diff = git_capture(&["diff", "--cached"]).or_else(|_| git_capture(&["diff"]))?; if diff.trim().is_empty() { println!("\nNo changes to show (no staged or unstaged diff)"); println!("\nTrying to show what would be staged with 'git add .'..."); git_run(&["add", "--dry-run", "."])?; } // Get AI session context since checkpoint println!("\n────────────────────────────────────────"); println!("AI SESSION CONTEXT (since checkpoint)"); println!("────────────────────────────────────────"); match ai::get_context_since_checkpoint() { Ok(Some(context)) => { println!( "Context length: {} chars, {} lines\n", context.len(), context.lines().count() ); println!("{}", context); } Ok(None) => { println!("No new AI session context since last checkpoint."); println!("\nThis could mean:"); println!(" - No exchanges since last commit"); println!(" - No Claude Code or Codex session in this project"); } Err(e) => { println!("Error getting context: {}", e); } } println!("────────────────────────────────────────"); println!("\nDiff that would be reviewed:"); println!("────────────────────────────────────────"); let (diff_for_prompt, truncated) = truncate_diff(&diff); println!("{}", diff_for_prompt); if truncated { println!("\n[Diff truncated to {} chars]", MAX_DIFF_CHARS); } println!("────────────────────────────────────────"); Ok(()) } /// Run the commit workflow: stage, generate message, commit, push. /// If hub is running, delegates to it for async execution. pub fn run( push: bool, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], ) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); // Check if hub is running - if so, delegate if hub::hub_healthy(HUB_HOST, HUB_PORT) { ensure_git_repo()?; let repo_root = git_root_or_cwd(); ensure_commit_setup(&repo_root)?; git_guard::ensure_clean_for_commit(&repo_root)?; if should_run_sync_for_secret_fixes(&repo_root)? { return run_sync(push, queue, include_unhash, stage_paths); } return delegate_to_hub(push, queue, include_unhash, stage_paths); } run_sync(push, queue, include_unhash, stage_paths) } fn save_commit_checkpoint_for_repo(repo_root: &Path) { let now = chrono::Utc::now().to_rfc3339(); let (session_id, last_ts) = match ai::get_last_entry_timestamp_for_path(&repo_root.to_path_buf()) { Ok(Some((session_id, last_ts))) => (Some(session_id), Some(last_ts)), Ok(None) => (None, Some(now.clone())), Err(err) => { debug!( "failed to resolve latest session timestamp for checkpoint: {}", err ); (None, Some(now.clone())) } }; let checkpoint = ai::CommitCheckpoint { timestamp: now, session_id, last_entry_timestamp: last_ts, }; if let Err(err) = ai::save_checkpoint(&repo_root.to_path_buf(), checkpoint) { debug!("failed to save commit checkpoint: {}", err); } } fn git_commit_timestamp_iso(repo_root: &Path, rev: &str) -> Option<String> { git_capture_in(repo_root, &["show", "-s", "--format=%cI", rev]) .ok() .map(|ts| ts.trim().to_string()) .filter(|ts| !ts.is_empty()) } #[derive(Debug, Clone)] struct MyflowSessionWindow { mode: String, since_ts: Option<String>, until_ts: Option<String>, collected_at: String, } impl MyflowSessionWindow { fn new(mode: &str, since_ts: Option<String>, until_ts: Option<String>) -> Self { Self { mode: mode.to_string(), since_ts, until_ts, collected_at: chrono::Utc::now().to_rfc3339(), } } } fn collect_sync_sessions_for_commit_with_window( repo_root: &Path, ) -> (Vec<ai::GitEditSessionData>, MyflowSessionWindow) { let until_ts = git_commit_timestamp_iso(repo_root, "HEAD"); let since_ts = git_commit_timestamp_iso(repo_root, "HEAD~1"); if until_ts.is_some() { match ai::get_sessions_for_gitedit_between( &repo_root.to_path_buf(), since_ts.as_deref(), until_ts.as_deref(), ) { Ok(sessions) => { return ( sessions, MyflowSessionWindow::new("commit_window", since_ts, until_ts), ); } Err(err) => { debug!( "failed to collect AI sessions in commit timestamp window (since={:?}, until={:?}): {}", since_ts, until_ts, err ); } } } match ai::get_sessions_for_gitedit(&repo_root.to_path_buf()) { Ok(sessions) => ( sessions, MyflowSessionWindow::new("checkpoint_fallback", since_ts, until_ts), ), Err(err) => { debug!( "failed to collect AI sessions using checkpoint fallback: {}", err ); ( Vec::new(), MyflowSessionWindow::new("checkpoint_fallback", since_ts, until_ts), ) } } } fn collect_sync_sessions_for_pending_commit_with_window( repo_root: &Path, ) -> (Vec<ai::GitEditSessionData>, MyflowSessionWindow) { // commit-with-check calls this before creating the new commit; use HEAD as the lower // bound and include everything after it so current-cycle AI exchanges are not dropped. let since_ts = git_commit_timestamp_iso(repo_root, "HEAD"); if since_ts.is_some() { match ai::get_sessions_for_gitedit_between( &repo_root.to_path_buf(), since_ts.as_deref(), None, ) { Ok(sessions) => { return ( sessions, MyflowSessionWindow::new("pending_window", since_ts, None), ); } Err(err) => { debug!( "failed to collect AI sessions for pending commit window (since={:?}): {}", since_ts, err ); } } } match ai::get_sessions_for_gitedit(&repo_root.to_path_buf()) { Ok(sessions) => ( sessions, MyflowSessionWindow::new("checkpoint_fallback", since_ts, None), ), Err(err) => { debug!( "failed to collect AI sessions using checkpoint fallback: {}", err ); ( Vec::new(), MyflowSessionWindow::new("checkpoint_fallback", since_ts, None), ) } } } /// Run commit synchronously (called directly or by hub). pub fn run_sync( push: bool, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], ) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); let queue_enabled = queue.enabled; let push = push && !queue_enabled; info!( push = push, queue = queue_enabled, "starting commit workflow" ); // Ensure we're in a git repo ensure_git_repo()?; debug!("verified git repository"); let repo_root = git_root_or_cwd(); warn_if_commit_invoked_from_subdir(&repo_root); ensure_commit_setup(&repo_root)?; git_guard::ensure_clean_for_commit(&repo_root)?; let commit_message_override = resolve_commit_message_override(&repo_root); debug!( has_override = commit_message_override.is_some(), "resolved commit message override" ); stage_changes_for_commit(&repo_root, stage_paths)?; debug!(paths = stage_paths.len(), "staged changes"); ensure_no_internal_staged(&repo_root)?; ensure_no_unwanted_staged(&repo_root)?; gitignore_policy::enforce_staged_policy(&repo_root)?; // Check for sensitive files before proceeding let sensitive_files = check_sensitive_files(&repo_root); warn_sensitive_files(&sensitive_files)?; // Scan diff content for hardcoded secrets let secret_findings = scan_diff_for_secrets(&repo_root); warn_secrets_in_diff(&repo_root, &secret_findings)?; // Check for files with large diffs let large_diffs = check_large_diffs(&repo_root); warn_large_diffs(&large_diffs)?; // Get diff let diff = git_capture_in(&repo_root, &["diff", "--cached"])?; if diff.trim().is_empty() { println!("\nnotify: No staged changes to commit"); print_pending_queue_review_hint(&repo_root); bail!("No staged changes to commit"); } debug!(diff_len = diff.len(), "got cached diff"); // Get status let status = git_capture_in(&repo_root, &["status", "--short"]).unwrap_or_default(); debug!(status_lines = status.lines().count(), "got git status"); // Truncate diff if needed let (diff_for_prompt, truncated) = truncate_diff(&diff); debug!( truncated = truncated, prompt_len = diff_for_prompt.len(), "prepared diff for prompt" ); // Generate commit message print!("Generating commit message... "); io::stdout().flush()?; let mut message = generate_commit_message_with_fallbacks( &repo_root, None, commit_message_override.as_ref(), &diff_for_prompt, &status, truncated, )?; println!("done\n"); debug!(message_len = message.len(), "got commit message"); if include_unhash && unhash_capture_enabled() { if let Some(unhash_hash) = capture_unhash_bundle( &repo_root, &diff, Some(&status), None, None, None, None, None, None, None, &message, None, false, ) { message = format!("{}\n\nunhash.sh/{}", message, unhash_hash); } } // Show the message println!("Commit message:"); println!("────────────────────────────────────────"); println!("{}", message); println!("────────────────────────────────────────\n"); // Commit let paragraphs = split_paragraphs(&message); debug!( paragraphs = paragraphs.len(), "split message into paragraphs" ); let mut args = vec!["commit"]; for p in ¶graphs { args.push("-m"); args.push(p); } git_run(&args)?; println!("✓ Committed"); info!("created commit"); log_commit_event_for_repo(&repo_root, &message, "commit", None, None); if queue_enabled { match queue_commit_for_review(&repo_root, &message, None, None, None, Vec::new()) { Ok(sha) => { print_queue_instructions(&repo_root, &sha); if queue.open_review { open_review_in_rise(&repo_root, &sha); } } Err(err) => println!("⚠ Failed to queue commit for review: {}", err), } } // Push if requested let mut pushed = false; if push { let push_remote = config::preferred_git_remote_for_repo(&repo_root); let push_branch = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); print!("Pushing... "); io::stdout().flush()?; match git_push_try(&push_remote, &push_branch) { PushResult::Success => { println!("done"); info!("pushed to remote"); pushed = true; } PushResult::NoRemoteRepo => { println!("skipped (no remote repo)"); info!("skipped push - remote repo does not exist"); } PushResult::RemoteAhead => { // Push failed, likely remote has new commits println!("failed (remote ahead)"); print!("Pulling with rebase... "); io::stdout().flush()?; match git_pull_rebase_try(&push_remote, &push_branch) { Ok(_) => { println!("done"); print!("Pushing... "); io::stdout().flush()?; git_push_run(&push_remote, &push_branch)?; println!("done"); info!("pulled and pushed to remote"); pushed = true; } Err(_) => { println!("conflict!"); println!(); println!("Rebase conflict detected. Resolve manually:"); println!(" 1. Fix conflicts in the listed files"); println!(" 2. git add <files>"); println!(" 3. git rebase --continue"); println!(" 4. git push"); println!(); println!("Or abort with: git rebase --abort"); bail!("Rebase conflict - manual resolution required"); } } } } } // Record undo action record_undo_action(&repo_root, pushed, Some(&message)); // Sync mirrors with AI sessions since previous checkpoint. let cwd = std::env::current_dir().unwrap_or_default(); let sync_gitedit = gitedit_globally_enabled() && gitedit_mirror_enabled_for_commit(&repo_root); let sync_myflow = myflow_mirror_enabled(&repo_root); let (sync_sessions, sync_window) = if sync_gitedit || sync_myflow { let (sessions, window) = collect_sync_sessions_for_commit_with_window(&repo_root); (sessions, Some(window)) } else { (Vec::new(), None) }; if sync_gitedit { sync_to_gitedit(&cwd, "commit", &sync_sessions, None, None); } if sync_myflow { sync_to_myflow( &repo_root, "commit", &sync_sessions, sync_window.as_ref(), None, None, ); } save_commit_checkpoint_for_repo(&repo_root); Ok(()) } /// Run a fast commit with the provided message (no AI review). pub fn run_fast( message: &str, push: bool, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], ) -> Result<()> { let queue_enabled = queue.enabled; let push = push && !queue_enabled; ensure_git_repo()?; let repo_root = git_root_or_cwd(); warn_if_commit_invoked_from_subdir(&repo_root); ensure_commit_setup(&repo_root)?; git_guard::ensure_clean_for_commit(&repo_root)?; // Run pre-commit fixers if configured (fast lint/format) if let Ok(fixed) = run_fixers(&repo_root) { if fixed { println!(); } } stage_changes_for_commit(&repo_root, stage_paths)?; ensure_no_internal_staged(&repo_root)?; ensure_no_unwanted_staged(&repo_root)?; gitignore_policy::enforce_staged_policy(&repo_root)?; // Check for sensitive files before proceeding let cwd = std::env::current_dir()?; let sensitive_files = check_sensitive_files(&cwd); warn_sensitive_files(&sensitive_files)?; // Scan diff content for hardcoded secrets let secret_findings = scan_diff_for_secrets(&cwd); warn_secrets_in_diff(&repo_root, &secret_findings)?; // Ensure we actually have changes let diff = git_capture(&["diff", "--cached"])?; if diff.trim().is_empty() { println!("\nnotify: No staged changes to commit"); print_pending_queue_review_hint(&repo_root); bail!("No staged changes to commit"); } let status = git_capture(&["status", "--short"]).unwrap_or_default(); let mut full_message = message.to_string(); if include_unhash && unhash_capture_enabled() { if let Some(unhash_hash) = capture_unhash_bundle( &repo_root, &diff, Some(&status), None, None, None, None, None, None, None, &full_message, None, false, ) { full_message = format!("{}\n\nunhash.sh/{}", full_message, unhash_hash); } } ensure_no_unwanted_staged(&repo_root)?; gitignore_policy::enforce_staged_policy(&repo_root)?; // Commit git_run(&["commit", "-m", &full_message])?; println!("✓ Committed"); log_commit_event_for_repo(&repo_root, &full_message, "commit", None, None); if queue_enabled { match queue_commit_for_review(&repo_root, &full_message, None, None, None, Vec::new()) { Ok(sha) => { print_queue_instructions(&repo_root, &sha); if queue.open_review { open_review_in_rise(&repo_root, &sha); } } Err(err) => println!("⚠ Failed to queue commit for review: {}", err), } } // Push if requested let mut pushed = false; if push { let push_remote = config::preferred_git_remote_for_repo(&repo_root); let push_branch = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); print!("Pushing... "); io::stdout().flush()?; match git_push_try(&push_remote, &push_branch) { PushResult::Success => { println!("done"); pushed = true; } PushResult::NoRemoteRepo => { println!("skipped (no remote repo)"); } PushResult::RemoteAhead => { println!("failed (remote ahead)"); print!("Pulling with rebase... "); io::stdout().flush()?; match git_pull_rebase_try(&push_remote, &push_branch) { Ok(_) => { println!("done"); print!("Pushing... "); io::stdout().flush()?; git_push_run(&push_remote, &push_branch)?; println!("done"); pushed = true; } Err(_) => { println!("conflict!"); println!(); println!("Rebase conflict detected. Resolve manually:"); println!(" 1. Fix conflicts in the listed files"); println!(" 2. git add <files>"); println!(" 3. git rebase --continue"); println!(" 4. git push"); println!(); println!("Or abort with: git rebase --abort"); bail!("Rebase conflict - manual resolution required"); } } } } } // Record undo action record_undo_action(&repo_root, pushed, Some(&full_message)); let sync_gitedit = gitedit_globally_enabled() && gitedit_mirror_enabled(); let sync_myflow = myflow_mirror_enabled(&repo_root); let (sync_sessions, sync_window) = if sync_gitedit || sync_myflow { let (sessions, window) = collect_sync_sessions_for_commit_with_window(&repo_root); (sessions, Some(window)) } else { (Vec::new(), None) }; if sync_gitedit { sync_to_gitedit(&cwd, "commit", &sync_sessions, None, None); } if sync_myflow { sync_to_myflow( &repo_root, "commit", &sync_sessions, sync_window.as_ref(), None, None, ); } save_commit_checkpoint_for_repo(&repo_root); Ok(()) } /// Commit immediately and trigger Codex queue review in the background. /// This gives a fast "commit now" UX while preserving deep review asynchronously. pub fn run_quick_then_async_review( push: bool, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], fast_message: Option<&str>, ) -> Result<()> { let explicit_no_queue = queue.override_flag == Some(false); if let Some(message) = fast_message { run_fast(message, push, queue, include_unhash, stage_paths)?; } else { run_sync(push, queue, include_unhash, stage_paths)?; } if explicit_no_queue { println!("Skipped async Codex review because --no-queue was requested."); return Ok(()); } let repo_root = git_root_or_cwd(); let commit_sha = git_capture_in(&repo_root, &["rev-parse", "--verify", "HEAD"])? .trim() .to_string(); if commit_sha.is_empty() { bail!("failed to resolve HEAD commit after quick commit"); } ensure_commit_queued_for_async_review(&repo_root, &commit_sha)?; match spawn_async_queue_review(&repo_root, &commit_sha) { Ok(()) => { println!( "Started async Codex review for {} (running in background).", short_sha(&commit_sha) ); println!( " Check status: f commit-queue show {}", short_sha(&commit_sha) ); } Err(err) => { println!("⚠️ Failed to start async review automatically: {}", err); println!( " Run manually: f commit-queue review {}", short_sha(&commit_sha) ); } } Ok(()) } fn ensure_commit_queued_for_async_review(repo_root: &Path, commit_sha: &str) -> Result<()> { if resolve_commit_queue_entry(repo_root, commit_sha).is_ok() { return Ok(()); } let entry = queue_existing_commit_for_approval(repo_root, commit_sha, false)?; println!("Queued {} for async review.", short_sha(&entry.commit_sha)); Ok(()) } fn spawn_async_queue_review(repo_root: &Path, commit_sha: &str) -> Result<()> { let flow_bin = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("f")); let mut cmd = Command::new(flow_bin); cmd.current_dir(repo_root) .arg("commit-queue") .arg("review") .arg(commit_sha) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); cmd.spawn() .context("failed to spawn background queue review")?; Ok(()) } /// Run commit with code review: stage, review with Codex or Claude, generate message, commit, push. /// If hub is running, delegates to it for async execution. pub fn run_with_check( push: bool, include_context: bool, review_selection: ReviewSelection, author_message: Option<&str>, max_tokens: usize, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], gate_overrides: CommitGateOverrides, ) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); if commit_with_check_async_enabled() && hub::hub_healthy(HUB_HOST, HUB_PORT) { ensure_git_repo()?; let repo_root = git_root_or_cwd(); ensure_commit_setup(&repo_root)?; git_guard::ensure_clean_for_commit(&repo_root)?; if should_run_sync_for_secret_fixes(&repo_root)? { return run_with_check_sync( push, include_context, review_selection, author_message, max_tokens, false, queue, include_unhash, stage_paths, gate_overrides, ); } return delegate_to_hub_with_check( "commitWithCheck", push, include_context, review_selection, author_message, max_tokens, queue, include_unhash, stage_paths, gate_overrides, ); } run_with_check_sync( push, include_context, review_selection, author_message, max_tokens, false, queue, include_unhash, stage_paths, gate_overrides, ) } /// Run commitWithCheck, honoring the global gitedit setting for sync/hash. pub fn run_with_check_with_gitedit( push: bool, include_context: bool, review_selection: ReviewSelection, author_message: Option<&str>, max_tokens: usize, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], gate_overrides: CommitGateOverrides, ) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); let force_gitedit = gitedit_globally_enabled(); if commit_with_check_async_enabled() && hub::hub_healthy(HUB_HOST, HUB_PORT) { ensure_git_repo()?; let repo_root = git_root_or_cwd(); ensure_commit_setup(&repo_root)?; git_guard::ensure_clean_for_commit(&repo_root)?; if should_run_sync_for_secret_fixes(&repo_root)? { return run_with_check_sync( push, include_context, review_selection, author_message, max_tokens, force_gitedit, queue, include_unhash, stage_paths, gate_overrides, ); } return delegate_to_hub_with_check( "commit", // CLI command name push, include_context, review_selection, author_message, max_tokens, queue, include_unhash, stage_paths, gate_overrides, ); } run_with_check_sync( push, include_context, review_selection, author_message, max_tokens, force_gitedit, queue, include_unhash, stage_paths, gate_overrides, ) } fn commit_with_check_async_enabled() -> bool { // Check TypeScript config first if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(commit) = flow.commit { if let Some(async_enabled) = commit.async_enabled { return async_enabled; } } } } let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { return cfg.options.commit_with_check_async.unwrap_or(true); } return true; } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { return cfg.options.commit_with_check_async.unwrap_or(true); } } true } fn commit_with_check_use_repo_root() -> bool { let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { return cfg.options.commit_with_check_use_repo_root.unwrap_or(true); } return true; } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { return cfg.options.commit_with_check_use_repo_root.unwrap_or(true); } } true } fn resolve_commit_with_check_root() -> Result<std::path::PathBuf> { if !commit_with_check_use_repo_root() { return std::env::current_dir().context("failed to get current directory"); } let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output() .context("failed to run git rev-parse --show-toplevel")?; if !output.status.success() { bail!("failed to resolve git repo root"); } let root = String::from_utf8_lossy(&output.stdout).trim().to_string(); if root.is_empty() { bail!("git repo root was empty"); } Ok(std::path::PathBuf::from(root)) } const DEFAULT_COMMIT_WITH_CHECK_TIMEOUT_SECS: u64 = 300; const MAX_COMMIT_WITH_CHECK_TIMEOUT_SECS: u64 = 3600; const DEFAULT_COMMIT_WITH_CHECK_REVIEW_RETRIES: u32 = 2; const MAX_COMMIT_WITH_CHECK_REVIEW_RETRIES: u32 = 5; const DEFAULT_COMMIT_WITH_CHECK_RETRY_BACKOFF_SECS: u64 = 3; fn commit_with_check_timeout_from_env() -> Option<u64> { for key in [ "FLOW_COMMIT_WITH_CHECK_TIMEOUT_SECS", "FLOW_COMMIT_TIMEOUT_SECS", ] { if let Ok(value) = env::var(key) { if let Ok(parsed) = value.trim().parse::<u64>() { if parsed > 0 { return Some(parsed.min(MAX_COMMIT_WITH_CHECK_TIMEOUT_SECS)); } } } } None } fn commit_with_check_timeout_secs() -> u64 { if let Some(timeout) = commit_with_check_timeout_from_env() { return timeout; } let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { return cfg .options .commit_with_check_timeout_secs .unwrap_or(DEFAULT_COMMIT_WITH_CHECK_TIMEOUT_SECS) .clamp(1, MAX_COMMIT_WITH_CHECK_TIMEOUT_SECS); } return DEFAULT_COMMIT_WITH_CHECK_TIMEOUT_SECS; } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { return cfg .options .commit_with_check_timeout_secs .unwrap_or(DEFAULT_COMMIT_WITH_CHECK_TIMEOUT_SECS) .clamp(1, MAX_COMMIT_WITH_CHECK_TIMEOUT_SECS); } } DEFAULT_COMMIT_WITH_CHECK_TIMEOUT_SECS } fn commit_with_check_review_retries() -> u32 { for key in [ "FLOW_COMMIT_WITH_CHECK_REVIEW_RETRIES", "FLOW_COMMIT_REVIEW_RETRIES", ] { if let Ok(value) = env::var(key) { if let Ok(parsed) = value.trim().parse::<u32>() { return parsed.min(MAX_COMMIT_WITH_CHECK_REVIEW_RETRIES); } } } let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { return cfg .options .commit_with_check_review_retries .unwrap_or(DEFAULT_COMMIT_WITH_CHECK_REVIEW_RETRIES) .min(MAX_COMMIT_WITH_CHECK_REVIEW_RETRIES); } return DEFAULT_COMMIT_WITH_CHECK_REVIEW_RETRIES; } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { return cfg .options .commit_with_check_review_retries .unwrap_or(DEFAULT_COMMIT_WITH_CHECK_REVIEW_RETRIES) .min(MAX_COMMIT_WITH_CHECK_REVIEW_RETRIES); } } DEFAULT_COMMIT_WITH_CHECK_REVIEW_RETRIES } fn commit_with_check_retry_backoff_secs(attempt: u32) -> u64 { let mut base = DEFAULT_COMMIT_WITH_CHECK_RETRY_BACKOFF_SECS; if let Ok(value) = env::var("FLOW_COMMIT_WITH_CHECK_RETRY_BACKOFF_SECS") { if let Ok(parsed) = value.trim().parse::<u64>() { if parsed > 0 { base = parsed.min(60); } } } base.saturating_mul(attempt as u64).min(120) } fn commit_with_check_review_url() -> Option<String> { if let Ok(url) = env::var("FLOW_REVIEW_URL") { let trimmed = url.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(url) = cfg.options.commit_with_check_review_url { let trimmed = url.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { if let Some(url) = cfg.options.commit_with_check_review_url { let trimmed = url.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } } if let Ok(Some(_token)) = crate::env::load_ai_auth_token() { if let Ok(api_url) = crate::env::load_ai_api_url() { let trimmed = api_url.trim().trim_end_matches('/').to_string(); if !trimmed.is_empty() { return Some(format!("{}/api/ai", trimmed)); } } } None } fn commit_with_check_review_token() -> Option<String> { if let Ok(token) = env::var("FLOW_REVIEW_TOKEN") { let trimmed = token.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(token) = cfg.options.commit_with_check_review_token { let trimmed = token.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { if let Some(token) = cfg.options.commit_with_check_review_token { let trimmed = token.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } } if let Ok(Some(token)) = crate::env::load_ai_auth_token() { let trimmed = token.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } None } pub fn resolve_commit_queue_mode(cli_queue: bool, cli_no_queue: bool) -> CommitQueueMode { if cli_no_queue { return CommitQueueMode { enabled: false, override_flag: Some(false), open_review: false, }; } if cli_queue { return CommitQueueMode { enabled: true, override_flag: Some(true), open_review: false, }; } CommitQueueMode { enabled: commit_queue_enabled_from_config(), override_flag: None, open_review: false, } } fn commit_queue_enabled_from_config() -> bool { if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(commit) = flow.commit { if let Some(queue_enabled) = commit.queue { return queue_enabled; } } } } let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(commit) = cfg.commit { if let Some(queue_enabled) = commit.queue { return queue_enabled; } } } } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { if let Some(commit) = cfg.commit { if let Some(queue_enabled) = commit.queue { return queue_enabled; } } } } true } fn commit_queue_on_issues_enabled(repo_root: &Path) -> bool { if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(commit) = flow.commit { if let Some(queue_on_issues) = commit.queue_on_issues { return queue_on_issues; } } } } let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(commit) = cfg.commit { if let Some(queue_on_issues) = commit.queue_on_issues { return queue_on_issues; } } } } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { if let Some(commit) = cfg.commit { if let Some(queue_on_issues) = commit.queue_on_issues { return queue_on_issues; } } } } false } fn prompt_yes_no(message: &str) -> Result<bool> { print!("{} [y/N]: ", message); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); Ok(answer == "y" || answer == "yes") } fn prompt_yes_no_default_yes(message: &str) -> Result<bool> { if !io::stdin().is_terminal() { return Ok(false); } print!("{} [Y/n]: ", message); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(true); } Ok(answer == "y" || answer == "yes") } fn resolve_commit_testing_policy(repo_root: &Path) -> CommitTestingPolicy { let cfg = config::load_or_default(repo_root.join("flow.toml")); let maybe_testing = cfg.commit.and_then(|commit| commit.testing); let Some(testing) = maybe_testing else { if is_bun_repo_layout(repo_root) { return CommitTestingPolicy { mode: "warn".to_string(), runner: "bun".to_string(), bun_repo_strict: true, require_related_tests: true, ai_scratch_test_dir: ".ai/test".to_string(), run_ai_scratch_tests: true, allow_ai_scratch_to_satisfy_gate: false, max_local_gate_seconds: 15, }; } return CommitTestingPolicy { mode: "off".to_string(), runner: "bun".to_string(), bun_repo_strict: true, require_related_tests: true, ai_scratch_test_dir: ".ai/test".to_string(), run_ai_scratch_tests: true, allow_ai_scratch_to_satisfy_gate: false, max_local_gate_seconds: 15, }; }; let mode = testing .mode .unwrap_or_else(|| "warn".to_string()) .to_ascii_lowercase(); let mode = match mode.as_str() { "warn" | "block" | "off" => mode, _ => "warn".to_string(), }; CommitTestingPolicy { mode, runner: testing .runner .unwrap_or_else(|| "bun".to_string()) .to_ascii_lowercase(), bun_repo_strict: testing.bun_repo_strict.unwrap_or(true), require_related_tests: testing.require_related_tests.unwrap_or(true), ai_scratch_test_dir: testing .ai_scratch_test_dir .unwrap_or_else(|| ".ai/test".to_string()), run_ai_scratch_tests: testing.run_ai_scratch_tests.unwrap_or(true), allow_ai_scratch_to_satisfy_gate: testing.allow_ai_scratch_to_satisfy_gate.unwrap_or(false), max_local_gate_seconds: testing.max_local_gate_seconds.unwrap_or(15), } } fn resolve_commit_skill_gate_policy(repo_root: &Path) -> CommitSkillGatePolicy { let cfg = config::load_or_default(repo_root.join("flow.toml")); let Some(skill_gate) = cfg.commit.and_then(|commit| commit.skill_gate) else { return CommitSkillGatePolicy { mode: "off".to_string(), required: Vec::new(), min_version: HashMap::new(), }; }; let mut required = skill_gate.required; required.retain(|name| !name.trim().is_empty()); required.sort(); required.dedup(); let default_mode = if required.is_empty() { "off" } else { "warn" }; let mode = skill_gate .mode .unwrap_or_else(|| default_mode.to_string()) .to_ascii_lowercase(); let mode = match mode.as_str() { "warn" | "block" | "off" => mode, _ => default_mode.to_string(), }; CommitSkillGatePolicy { mode, required, min_version: skill_gate.min_version.unwrap_or_default(), } } fn run_required_skill_gate( repo_root: &Path, gate_overrides: CommitGateOverrides, ) -> Result<SkillGateReport> { if gate_overrides.skip_quality { return Ok(SkillGateReport { pass: true, mode: "off".to_string(), override_flag: Some("skip-quality".to_string()), ..SkillGateReport::default() }); } let policy = resolve_commit_skill_gate_policy(repo_root); if policy.mode == "off" || policy.required.is_empty() { return Ok(SkillGateReport { pass: true, mode: policy.mode, required_skills: policy.required, ..SkillGateReport::default() }); } let mut report = SkillGateReport { pass: true, mode: policy.mode.clone(), override_flag: None, required_skills: policy.required.clone(), missing_skills: Vec::new(), version_failures: Vec::new(), loaded_versions: HashMap::new(), }; for skill_name in &policy.required { let skill_content = skills::read_skill_content_at(repo_root, skill_name)?; if skill_content.is_none() { report.missing_skills.push(skill_name.clone()); continue; } if let Some(required_version) = policy.min_version.get(skill_name) { let local_version = skills::read_skill_version_at(repo_root, skill_name)?; match local_version { Some(version) => { report.loaded_versions.insert(skill_name.clone(), version); if version < *required_version { report.version_failures.push(format!( "{} has version {}, requires >= {}", skill_name, version, required_version )); } } None => { report.version_failures.push(format!( "{} is missing frontmatter version (requires >= {})", skill_name, required_version )); } } } else if let Some(version) = skills::read_skill_version_at(repo_root, skill_name)? { report.loaded_versions.insert(skill_name.clone(), version); } } report.pass = report.missing_skills.is_empty() && report.version_failures.is_empty(); if !report.pass { for missing in &report.missing_skills { eprintln!( " skills: required skill '{}' is missing in .ai/skills/", missing ); } for failure in &report.version_failures { eprintln!(" skills: {}", failure); } if policy.mode == "block" { bail!("Commit blocked by required skill gate"); } eprintln!(" skills: warning only (mode=warn)"); } Ok(report) } fn build_required_skills_prompt_context( repo_root: &Path, skill_report: &SkillGateReport, ) -> String { if skill_report.required_skills.is_empty() { return String::new(); } let mut sections = Vec::new(); for skill_name in &skill_report.required_skills { if let Ok(Some(content)) = skills::read_skill_content_at(repo_root, skill_name) { sections.push(format!("## Skill: {}\n{}", skill_name, content)); } } if sections.is_empty() { return String::new(); } format!( "\nRequired workflow skills for this repo. Follow these constraints while reviewing and generating output:\n\n{}\n", sections.join("\n\n") ) } fn combine_review_instructions( custom: Option<&str>, required_skill_context: &str, ) -> Option<String> { let mut parts = Vec::new(); if let Some(custom) = custom { if !custom.trim().is_empty() { parts.push(custom.trim().to_string()); } } if !required_skill_context.trim().is_empty() { parts.push(required_skill_context.trim().to_string()); } if parts.is_empty() { None } else { Some(parts.join("\n\n")) } } // --------------------------------------------------------------------------- // Invariant gate: check staged diff against [invariants] from flow.toml // --------------------------------------------------------------------------- #[derive(Debug, Default)] struct InvariantFinding { severity: String, // "critical" | "warning" | "note" category: String, // "forbidden" | "deps" | "files" | "terminology" message: String, file: Option<String>, } #[derive(Debug, Default)] struct InvariantGateReport { findings: Vec<InvariantFinding>, } impl InvariantGateReport { /// Build prompt context from findings + invariants for AI review injection. fn to_prompt_context(&self, inv: &config::InvariantsConfig) -> String { let mut parts = Vec::new(); // Always inject invariants into prompt even if no findings. if let Some(style) = inv.architecture_style.as_deref() { parts.push(format!("Architecture: {}", style)); } if !inv.non_negotiable.is_empty() { parts.push(format!( "Non-negotiable rules:\n{}", inv.non_negotiable .iter() .map(|r| format!("- {}", r)) .collect::<Vec<_>>() .join("\n") )); } if !inv.terminology.is_empty() { let terms: Vec<String> = inv .terminology .iter() .map(|(k, v)| format!("- {}: {}", k, v)) .collect(); parts.push(format!( "Terminology (do not rename):\n{}", terms.join("\n") )); } if !self.findings.is_empty() { let finding_lines: Vec<String> = self .findings .iter() .map(|f| { let loc = f.file.as_deref().unwrap_or("(repo)"); format!(" [{}] {} — {}", f.severity, loc, f.message) }) .collect(); parts.push(format!( "Invariant findings on staged files:\n{}", finding_lines.join("\n") )); } if parts.is_empty() { return String::new(); } format!( "\n## Project Invariants (enforced by flow)\n\n{}\n", parts.join("\n\n") ) } } fn resolve_invariants_config(repo_root: &Path) -> Option<config::InvariantsConfig> { let cfg = config::load_or_default(repo_root.join("flow.toml")); cfg.invariants } fn run_invariant_gate( repo_root: &Path, diff: &str, changed_files: &[String], gate_overrides: CommitGateOverrides, ) -> Result<InvariantGateReport> { if gate_overrides.skip_quality { return Ok(InvariantGateReport { findings: Vec::new(), }); } let Some(inv) = resolve_invariants_config(repo_root) else { return Ok(InvariantGateReport { findings: Vec::new(), }); }; let mode = inv.mode.as_deref().unwrap_or("warn").to_ascii_lowercase(); if mode == "off" { return Ok(InvariantGateReport { findings: Vec::new(), }); } let mut findings: Vec<InvariantFinding> = Vec::new(); // 1. Forbidden patterns in diff content. let skip_files = ["flow.toml"]; for pattern in &inv.forbidden { let pat_lower = pattern.to_lowercase(); let mut current_file: Option<String> = None; let mut skip_current = false; for line in diff.lines() { if let Some(file) = line.strip_prefix("+++ b/") { let file = file.trim().trim_matches('"'); current_file = Some(file.to_string()); skip_current = skip_files.iter().any(|s| file.ends_with(s)); continue; } if current_file .as_deref() .is_some_and(|f| f.trim().trim_matches('"').ends_with("flow.toml")) { continue; } if skip_current { continue; } // Only check added lines (lines starting with +, excluding +++ header). if !line.starts_with('+') || line.starts_with("+++") { continue; } if line.to_lowercase().contains(&pat_lower) { findings.push(InvariantFinding { severity: "warning".to_string(), category: "forbidden".to_string(), message: format!("Forbidden pattern '{}' in added line", pattern), file: current_file.clone(), }); break; // One finding per pattern is enough. } } } // 2. Dependency policy: check package.json changes for unapproved deps. if let Some(deps_config) = &inv.deps { let policy = deps_config.policy.as_deref().unwrap_or("approval_required"); if policy == "approval_required" && !deps_config.approved.is_empty() { for file in changed_files { if file.ends_with("package.json") { let full = repo_root.join(file); if let Ok(contents) = fs::read_to_string(&full) { check_unapproved_deps( &contents, &deps_config.approved, file, &mut findings, ); } } } } } // 3. File size limits. if let Some(files_config) = &inv.files { if let Some(max_lines) = files_config.max_lines { for file in changed_files { let full = repo_root.join(file); if let Ok(contents) = fs::read_to_string(&full) { let line_count = contents.lines().count() as u32; if line_count > max_lines { findings.push(InvariantFinding { severity: "warning".to_string(), category: "files".to_string(), message: format!("File has {} lines (max {})", line_count, max_lines), file: Some(file.clone()), }); } } } } } let has_blocking = findings .iter() .any(|f| f.severity == "critical" || f.severity == "warning"); // Print findings. if !findings.is_empty() { eprintln!(); eprintln!(" invariants: {} finding(s)", findings.len()); for f in &findings { let loc = f.file.as_deref().unwrap_or("(diff)"); eprintln!( " [{}:{}] {} — {}", f.severity, f.category, loc, f.message ); } } let pass = !has_blocking; if !pass && mode == "block" { bail!( "Commit blocked by invariant gate ({} finding(s))", findings.len() ); } if !pass { eprintln!(" invariants: warning only (mode=warn)"); } Ok(InvariantGateReport { findings }) } /// Check a package.json for dependencies not on the approved list. fn check_unapproved_deps( package_json: &str, approved: &[String], file_path: &str, findings: &mut Vec<InvariantFinding>, ) { let Ok(parsed) = serde_json::from_str::<serde_json::Value>(package_json) else { return; }; let dep_sections = ["dependencies", "devDependencies", "peerDependencies"]; for section in &dep_sections { if let Some(deps) = parsed.get(section).and_then(|v| v.as_object()) { for dep_name in deps.keys() { if !approved.iter().any(|a| a == dep_name) { findings.push(InvariantFinding { severity: "warning".to_string(), category: "deps".to_string(), message: format!( "'{}' in {} is not on the approved list", dep_name, section ), file: Some(file_path.to_string()), }); } } } } } fn is_bun_repo_layout(repo_root: &Path) -> bool { if repo_root.join("build.zig").exists() && repo_root.join("src/bun.js").exists() { return true; } let agents_file = repo_root.join("AGENTS.md"); if let Ok(contents) = fs::read_to_string(agents_file) { return contents.contains("This is the Bun repository"); } false } fn looks_like_source_file_for_test_gate(path: &str) -> bool { let normalized = path.replace('\\', "/"); let ext = Path::new(&normalized) .extension() .and_then(|s| s.to_str()) .unwrap_or("") .to_ascii_lowercase(); matches!( ext.as_str(), "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "rs" ) } fn is_test_file_path(path: &str) -> bool { let normalized = path.replace('\\', "/").to_ascii_lowercase(); normalized.contains("/__tests__/") || normalized.ends_with(".test.js") || normalized.ends_with(".test.jsx") || normalized.ends_with(".test.ts") || normalized.ends_with(".test.tsx") || normalized.ends_with(".spec.js") || normalized.ends_with(".spec.jsx") || normalized.ends_with(".spec.ts") || normalized.ends_with(".spec.tsx") || normalized.ends_with("_test.rs") } fn normalize_rel_path(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } fn normalize_dir_path(path: &str) -> String { let mut normalized = path.replace('\\', "/"); while normalized.starts_with("./") { normalized = normalized.trim_start_matches("./").to_string(); } normalized.trim_end_matches('/').to_string() } fn path_is_within_dir(path: &str, dir: &str) -> bool { let normalized_path = normalize_dir_path(path); let normalized_dir = normalize_dir_path(dir); if normalized_dir.is_empty() { return false; } normalized_path == normalized_dir || normalized_path.starts_with(&(normalized_dir + "/")) } fn find_ai_scratch_tests(repo_root: &Path, scratch_dir: &str) -> Vec<String> { let scratch_dir = normalize_dir_path(scratch_dir); if scratch_dir.is_empty() { return Vec::new(); } let scratch_root = repo_root.join(&scratch_dir); if !scratch_root.is_dir() { return Vec::new(); } let mut out = HashSet::new(); let mut stack = vec![scratch_root]; while let Some(dir) = stack.pop() { let Ok(entries) = fs::read_dir(&dir) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); continue; } if !path.is_file() { continue; } let Ok(rel) = path.strip_prefix(repo_root) else { continue; }; let rel = normalize_rel_path(rel); if is_test_file_path(&rel) { out.insert(rel); } } } let mut tests: Vec<String> = out.into_iter().collect(); tests.sort(); tests } fn collect_candidate_js_test_paths(rel_path: &Path) -> Vec<PathBuf> { const JS_EXTS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs", "cjs"]; let mut out = Vec::new(); let parent = rel_path.parent().unwrap_or_else(|| Path::new("")); let stem = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if stem.is_empty() { return out; } let base_no_ext = parent.join(stem); for ext in JS_EXTS { let mut same_dir_test = base_no_ext.clone(); same_dir_test.set_extension(format!("test.{}", ext)); out.push(same_dir_test); let mut same_dir_spec = base_no_ext.clone(); same_dir_spec.set_extension(format!("spec.{}", ext)); out.push(same_dir_spec); let mut in_test_dir = PathBuf::from("test").join(&base_no_ext); in_test_dir.set_extension(format!("test.{}", ext)); out.push(in_test_dir); let mut in_tests_dir = PathBuf::from("tests").join(&base_no_ext); in_tests_dir.set_extension(format!("test.{}", ext)); out.push(in_tests_dir); } let tests_dir = parent.join("__tests__"); if let Some(file_name) = rel_path.file_name() { out.push(tests_dir.join(file_name)); } out } fn collect_candidate_rust_test_paths(rel_path: &Path) -> Vec<PathBuf> { let mut out = Vec::new(); let parent = rel_path.parent().unwrap_or_else(|| Path::new("")); let stem = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if stem.is_empty() { return out; } out.push(parent.join(format!("{}_test.rs", stem))); out.push(PathBuf::from("tests").join(format!("{}.rs", stem))); out.push(PathBuf::from("tests").join(format!("{}_test.rs", stem))); let mut tests_rel = PathBuf::from("tests").join(rel_path); tests_rel.set_extension("rs"); out.push(tests_rel); out } fn find_related_tests( repo_root: &Path, changed_files: &[String], ai_scratch_test_dir: &str, ) -> Vec<String> { let mut tests = HashSet::new(); for changed in changed_files { let normalized = changed.replace('\\', "/"); if path_is_within_dir(&normalized, ai_scratch_test_dir) { continue; } if is_test_file_path(&normalized) { tests.insert(normalized); continue; } if !looks_like_source_file_for_test_gate(&normalized) { continue; } let rel = Path::new(&normalized); let ext = rel .extension() .and_then(|s| s.to_str()) .unwrap_or("") .to_ascii_lowercase(); let candidates = if ext == "rs" { collect_candidate_rust_test_paths(rel) } else { collect_candidate_js_test_paths(rel) }; for candidate in candidates { if repo_root.join(&candidate).is_file() { let candidate = normalize_rel_path(&candidate); if !path_is_within_dir(&candidate, ai_scratch_test_dir) { tests.insert(candidate); } } } } let mut out: Vec<String> = tests.into_iter().collect(); out.sort(); out } fn find_non_bun_test_tasks(repo_root: &Path, strict_bun_repo: bool) -> Vec<String> { let config_path = repo_root.join("flow.toml"); if !config_path.exists() { return Vec::new(); } let cfg = match config::load(&config_path) { Ok(cfg) => cfg, Err(_) => return Vec::new(), }; let mut violations = Vec::new(); for task in cfg.tasks { let name = task.name.to_ascii_lowercase(); let cmd = task.command.to_ascii_lowercase(); let looks_like_test_task = name.contains("test") || cmd.starts_with("test ") || cmd.contains(" test ") || cmd.contains("bun test") || cmd.contains("bun bd test"); if !looks_like_test_task { continue; } if !cmd.contains("bun ") { violations.push(format!( "task '{}' must use bun: {}", task.name, task.command )); continue; } if strict_bun_repo && !cmd.contains("bun bd test") { violations.push(format!( "task '{}' must use `bun bd test` in Bun repo: {}", task.name, task.command )); } } violations } fn apply_testing_gate_failure(mode: &str, message: &str) -> Result<()> { eprintln!(" testing: {}", message); if mode == "block" { bail!("Commit blocked by testing gate"); } eprintln!(" testing: warning only (mode=warn)"); Ok(()) } fn run_pre_commit_test_gate( repo_root: &Path, changed_files: &[String], gate_overrides: CommitGateOverrides, ) -> Result<()> { if gate_overrides.skip_quality || gate_overrides.skip_tests { if gate_overrides.skip_tests { println!("Skipping test gate due to --skip-tests"); } return Ok(()); } let policy = resolve_commit_testing_policy(repo_root); if policy.mode == "off" { return Ok(()); } if policy.runner != "bun" { return apply_testing_gate_failure( &policy.mode, &format!( "unsupported test runner '{}'; only bun is currently supported", policy.runner ), ); } let strict_bun_repo = policy.bun_repo_strict && is_bun_repo_layout(repo_root); let task_violations = find_non_bun_test_tasks(repo_root, strict_bun_repo); if !task_violations.is_empty() { return apply_testing_gate_failure( &policy.mode, &format!( "flow.toml test tasks are not Bun-compliant:\n {}", task_violations.join("\n ") ), ); } let has_source_changes = changed_files .iter() .any(|p| looks_like_source_file_for_test_gate(p) && !is_test_file_path(p)); if !has_source_changes { return Ok(()); } let related_tests = find_related_tests(repo_root, changed_files, &policy.ai_scratch_test_dir); let run_bun_tests = |tests: &[String], label: &str| -> Result<()> { let mut args: Vec<String> = Vec::new(); if strict_bun_repo { args.push("bd".to_string()); args.push("test".to_string()); } else { args.push("test".to_string()); } args.extend(tests.iter().cloned()); println!(); println!("Running local test gate (bun) for {}...", label); println!("Command: bun {}", args.join(" ")); let started_at = Instant::now(); let status = match Command::new("bun") .args(args.iter().map(|s| s.as_str())) .current_dir(repo_root) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() { Ok(status) => status, Err(err) => { return apply_testing_gate_failure( &policy.mode, &format!("failed to execute bun test gate: {}", err), ); } }; let elapsed = started_at.elapsed(); if elapsed > Duration::from_secs(policy.max_local_gate_seconds) { eprintln!( " testing: local gate exceeded target budget ({}s > {}s)", elapsed.as_secs(), policy.max_local_gate_seconds ); } if !status.success() { return apply_testing_gate_failure( &policy.mode, &format!("bun tests failed for {} (exit status: {})", label, status), ); } Ok(()) }; if related_tests.is_empty() { if policy.run_ai_scratch_tests { let scratch_tests = find_ai_scratch_tests(repo_root, &policy.ai_scratch_test_dir); if !scratch_tests.is_empty() { run_bun_tests(&scratch_tests, "AI scratch tests")?; println!( "✓ AI scratch tests passed ({} test file{})", scratch_tests.len(), if scratch_tests.len() == 1 { "" } else { "s" } ); if policy.allow_ai_scratch_to_satisfy_gate { println!( "✓ Test gate satisfied by AI scratch tests ({})", policy.ai_scratch_test_dir ); return Ok(()); } } } if policy.require_related_tests { return apply_testing_gate_failure( &policy.mode, &format!( "no related tracked test files detected for staged source changes (AI scratch dir: {}; set commit.testing.allow_ai_scratch_to_satisfy_gate=true to allow scratch-only satisfaction)", policy.ai_scratch_test_dir ), ); } return Ok(()); } run_bun_tests(&related_tests, "related tracked tests")?; println!( "✓ Test gate passed ({} related tracked test file{})", related_tests.len(), if related_tests.len() == 1 { "" } else { "s" } ); Ok(()) } fn is_doc_gate_failure(message: &str) -> bool { let m = message.to_ascii_lowercase(); m.contains("doc") || m.contains("documentation") } fn is_test_gate_failure(message: &str) -> bool { let m = message.to_ascii_lowercase(); m.contains("test") || m.contains("coverage") } fn run_review_attempt( selection: &ReviewSelection, diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, repo_root: &Path, ) -> Result<(ReviewResult, &'static str, String)> { match selection { ReviewSelection::Claude(model) => Ok(( run_claude_review( diff, session_context, review_instructions, repo_root, *model, )?, "claude", model.as_claude_arg().to_string(), )), ReviewSelection::Codex(model) => Ok(( run_codex_review( diff, session_context, review_instructions, repo_root, *model, )?, "codex", model.as_codex_arg().to_string(), )), ReviewSelection::Opencode { model } => Ok(( run_opencode_review(diff, session_context, review_instructions, repo_root, model)?, "opencode", model.clone(), )), ReviewSelection::OpenRouter { model } => Ok(( run_openrouter_review(diff, session_context, review_instructions, repo_root, model)?, "openrouter", openrouter_model_label(model), )), ReviewSelection::Rise { model } => Ok(( run_rise_review(diff, session_context, review_instructions, repo_root, model)?, "rise", format!("rise:{}", model), )), ReviewSelection::Kimi { model } => Ok(( run_kimi_review( diff, session_context, review_instructions, repo_root, model.as_deref(), )?, "kimi", match model.as_deref() { Some(model) if !model.trim().is_empty() => format!("kimi:{}", model), _ => "kimi".to_string(), }, )), } } /// Run commit with code review synchronously (called directly or by hub). /// If `include_context` is true, AI session context is passed for better understanding. /// `review_selection` determines whether Claude or Codex runs and which model is used. /// If `author_message` is provided, it's appended to the commit message. pub fn run_with_check_sync( push: bool, include_context: bool, review_selection: ReviewSelection, author_message: Option<&str>, max_tokens: usize, force_gitedit: bool, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], gate_overrides: CommitGateOverrides, ) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); let push_requested = push; let mut queue_enabled = queue.enabled; let prefer_codex_over_openrouter = review_selection.is_openrouter() && openrouter_review_should_use_codex(); // Convert tokens to chars (roughly 4 chars per token) let max_context = max_tokens * 4; info!( push = push_requested && !queue_enabled, queue = queue_enabled, include_context = include_context, review_model = if prefer_codex_over_openrouter { CodexModel::High.as_codex_arg().to_string() } else { review_selection.model_label() }, max_tokens = max_tokens, "starting commit with check workflow" ); // Ensure we're in a git repo ensure_git_repo()?; let repo_root = resolve_commit_with_check_root()?; warn_if_commit_invoked_from_subdir(&repo_root); ensure_commit_setup(&repo_root)?; git_guard::ensure_clean_for_commit(&repo_root)?; // Capture current staged changes so we can restore if we cancel. let staged_snapshot = capture_staged_snapshot_in(&repo_root)?; // Run pre-commit fixers if configured if let Ok(fixed) = run_fixers(&repo_root) { if fixed { println!(); } } stage_changes_for_commit(&repo_root, stage_paths)?; ensure_no_internal_staged(&repo_root)?; gitignore_policy::enforce_staged_policy(&repo_root)?; // Check for sensitive files before proceeding let sensitive_files = check_sensitive_files(&repo_root); warn_sensitive_files(&sensitive_files)?; // Scan diff content for hardcoded secrets let secret_findings = scan_diff_for_secrets(&repo_root); warn_secrets_in_diff(&repo_root, &secret_findings)?; // Check for files with large diffs let large_diffs = check_large_diffs(&repo_root); warn_large_diffs(&large_diffs)?; // Get diff let diff = git_capture_in(&repo_root, &["diff", "--cached"])?; if diff.trim().is_empty() { println!("\nnotify: No staged changes to commit"); print_pending_queue_review_hint(&repo_root); bail!("No staged changes to commit"); } let changed_files = changed_files_from_diff(&diff); // Enforce required workflow skills before review. let skill_gate_report = run_required_skill_gate(&repo_root, gate_overrides)?; // Fast feedback loop: run impacted tests with Bun before AI review. run_pre_commit_test_gate(&repo_root, &changed_files, gate_overrides)?; // Enforce project invariants (forbidden patterns, dep policy, file size). let invariant_report = run_invariant_gate(&repo_root, &diff, &changed_files, gate_overrides)?; // Get AI session context since last checkpoint (if enabled) let session_context = if include_context { ai::get_context_since_checkpoint_for_path(&repo_root) .ok() .flatten() .map(|context| truncate_context(&context, max_context)) } else { None }; if let Some(context) = session_context.as_ref() { let line_count = context.lines().count(); println!( "Using AI session context ({} chars, {} lines) since last checkpoint", context.len(), line_count ); if should_show_review_context() { println!("--- AI session context ---"); println!("{}", context); println!("--- End AI session context ---"); } } // Merge [commit] review instructions with required skill + invariant instructions. let custom_review_instructions = get_review_instructions(&repo_root); let required_skill_context = build_required_skills_prompt_context(&repo_root, &skill_gate_report); let invariant_context = resolve_invariants_config(&repo_root) .map(|inv| invariant_report.to_prompt_context(&inv)) .unwrap_or_default(); let combined_extra = format!("{}{}", required_skill_context, invariant_context); let review_instructions = combine_review_instructions(custom_review_instructions.as_deref(), &combined_extra); // Run code review with configured fallbacks. let review_attempts = review_attempts_for_selection(&repo_root, &review_selection, prefer_codex_over_openrouter); let primary_review_attempt = review_attempts .first() .cloned() .unwrap_or_else(|| review_selection.clone()); println!( "\nRunning {} review...", review_tool_label(&primary_review_attempt) ); println!("Model: {}", primary_review_attempt.model_label()); if session_context.is_some() { println!("(with AI session context)"); } if custom_review_instructions.is_some() || !required_skill_context.is_empty() || !invariant_context.trim().is_empty() { println!("(with custom review instructions)"); } println!("────────────────────────────────────────"); let mut review_reviewer_label = "codex"; let mut review_model_label = primary_review_attempt.model_label(); let mut review_selection_used = primary_review_attempt.clone(); let mut review_failures: Vec<String> = Vec::new(); let mut review_result: Option<ReviewResult> = None; for (idx, attempt) in review_attempts.iter().enumerate() { if idx > 0 { println!("────────────────────────────────────────"); println!( "Retrying review with fallback: {} ({})", review_tool_label(attempt), attempt.model_label() ); println!("────────────────────────────────────────"); } match run_review_attempt( attempt, &diff, session_context.as_deref(), review_instructions.as_deref(), &repo_root, ) { Ok((review, reviewer_label, model_label)) => { review_reviewer_label = reviewer_label; review_model_label = model_label; review_selection_used = attempt.clone(); review_result = Some(review); break; } Err(err) => { let error_message = format!( "{} ({}) failed: {}", review_tool_label(attempt), attempt.model_label(), err ); review_failures.push(error_message.clone()); if idx + 1 < review_attempts.len() { println!("⚠ {}. Trying next fallback...", error_message); } } } } let mut review_failed_open = false; let review = if let Some(review) = review_result { review } else if commit_review_fail_open_enabled(&repo_root) { review_failed_open = true; println!( "⚠ Review failed across all attempts; continuing because commit.review_fail_open = true." ); if let Some(last_error) = review_failures.last() { println!("Last review error: {}", last_error); } ReviewResult { issues_found: false, issues: Vec::new(), summary: Some(format!( "Review unavailable; commit proceeded in fail-open mode after {} failed attempt(s).", review_failures.len() )), future_tasks: Vec::new(), timed_out: true, quality: None, } } else { restore_staged_snapshot_in(&repo_root, &staged_snapshot)?; if review_failures.is_empty() { bail!("review failed: no review attempts were available"); } bail!("review failed:\n {}", review_failures.join("\n ")); }; println!("────────────────────────────────────────\n"); // Log review result for async tracking let context_chars = session_context.as_ref().map(|c| c.len()).unwrap_or(0); ai::log_review_result( &repo_root, review.issues_found, &review.issues, context_chars, 0, // TODO: track actual review time ); if review.timed_out { if review_failed_open { println!("⚠ Review unavailable after fallback attempts, proceeding anyway"); } else { println!( "⚠ Review timed out after {}s, proceeding anyway", commit_with_check_timeout_secs() ); } } // Show review results (informational only, never blocks) if review.issues_found { if let Some(summary) = review.summary.as_ref() { if !summary.trim().is_empty() { println!("Summary: {}", summary.trim()); println!(); } } if !review.issues.is_empty() { println!("Issues found:"); for issue in &review.issues { println!("- {}", issue); } println!(); // Send notification for critical issues (secrets, security) let critical_issues: Vec<_> = review .issues .iter() .filter(|i| { let lower = i.to_lowercase(); lower.contains("secret") || lower.contains(".env") || lower.contains("credential") || lower.contains("api key") || lower.contains("password") || lower.contains("token") || lower.contains("security") || lower.contains("vulnerability") }) .collect(); if !critical_issues.is_empty() { let alert_msg = format!( "⚠️ Review found {} critical issue(s): {}", critical_issues.len(), critical_issues .iter() .map(|s| s.as_str()) .collect::<Vec<_>>() .join("; ") ); // Truncate if too long let alert_msg = if alert_msg.len() > 200 { format!("{}...", &alert_msg[..200]) } else { alert_msg }; let _ = notify::send_warning(&alert_msg); // Also try to POST to cloud send_to_cloud(&repo_root, &review.issues, review.summary.as_deref()); } } println!("Proceeding with commit..."); } else if !review.timed_out { if let Some(summary) = review.summary.as_ref() { if !summary.trim().is_empty() { println!("Summary: {}", summary.trim()); println!(); } } println!("✓ Review passed"); } // ── Quality gate check ───────────────────────────────────────── if gate_overrides.skip_quality { println!("Skipping quality gates due to --skip-quality"); } else if let Some(ref quality) = review.quality { let quality_config = config::load_or_default(repo_root.join("flow.toml")) .commit .and_then(|c| c.quality) .unwrap_or_default(); let mode = quality_config.mode.as_deref().unwrap_or("warn"); let mut gate_failures: Vec<String> = quality.gate_failures.clone(); if gate_overrides.skip_docs { gate_failures.retain(|failure| !is_doc_gate_failure(failure)); } if gate_overrides.skip_tests { gate_failures.retain(|failure| !is_test_gate_failure(failure)); } if !gate_failures.is_empty() && mode != "off" { println!(); for failure in &gate_failures { eprintln!(" quality: {}", failure); } if mode == "block" { eprintln!("\nCommit blocked by quality gates."); eprintln!("Fix the issues above, or override with: f commit --skip-quality"); restore_staged_snapshot_in(&repo_root, &staged_snapshot)?; bail!("Quality gate blocked commit"); } else { eprintln!("\nQuality warnings above. Proceeding with commit."); } } // Auto-generate/update feature docs if enabled let auto_docs = quality_config.auto_generate_docs.unwrap_or(true); if auto_docs && mode != "off" && !gate_overrides.skip_docs { let commit_sha_preview = git_capture_in(&repo_root, &["rev-parse", "--short", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); match features::apply_quality_results(&repo_root, quality, &commit_sha_preview) { Ok(actions) => { for action in &actions { println!(" feature docs: {}", action); } // Stage .ai/features/ changes if !actions.is_empty() { let _ = std::process::Command::new("git") .args(["add", ".ai/features/"]) .current_dir(&repo_root) .output(); } } Err(e) => { eprintln!(" warning: failed to update feature docs: {}", e); } } } } if queue_enabled && queue.override_flag.is_none() && commit_queue_on_issues_enabled(&repo_root) { if review.issues_found || review.timed_out { println!("ℹ️ Review found issues; keeping commit queued for approval."); } else { println!("ℹ️ Review passed; skipping queue because commit.queue_on_issues = true."); queue_enabled = false; } } let push = push_requested && !queue_enabled; let review_run_id = flow_review_run_id( &repo_root, &diff, &review_model_label, review_reviewer_label, ); // Continue with normal commit flow let commit_message_override = resolve_commit_message_override(&repo_root); // Get status let status = git_capture_in(&repo_root, &["status", "--short"]).unwrap_or_default(); // Truncate diff if needed let (diff_for_prompt, truncated) = truncate_diff(&diff); // Generate commit message based on the review tool print!("Generating commit message... "); io::stdout().flush()?; let message = generate_commit_message_with_fallbacks( &repo_root, Some(&review_selection_used), commit_message_override.as_ref(), &diff_for_prompt, &status, truncated, )?; println!("done\n"); // Best-effort: write a private review record into repo-local beads history for later triage. // This is written into `.beads/.br_history` inside the current repository. if let Err(err) = write_beads_commit_review_record( &repo_root, review_reviewer_label, &review_model_label, &review, Some(&message), ) { debug!( "failed to write commit review record to repo-local beads: {}", err ); } let mut gitedit_sessions: Vec<ai::GitEditSessionData> = Vec::new(); let mut gitedit_session_hash: Option<String> = None; let gitedit_mirror_enabled = if force_gitedit { gitedit_mirror_enabled_for_commit(&repo_root) } else { gitedit_mirror_enabled_for_commit_with_check(&repo_root) }; let gitedit_enabled = gitedit_globally_enabled() && gitedit_mirror_enabled; let unhash_enabled = include_unhash && unhash_capture_enabled(); let mut unhash_sessions: Vec<ai::GitEditSessionData> = Vec::new(); let mut pending_sync_window: Option<MyflowSessionWindow> = None; if gitedit_enabled || unhash_enabled { let (sessions, window) = collect_sync_sessions_for_pending_commit_with_window(&repo_root); pending_sync_window = Some(window); if !sessions.is_empty() { if gitedit_enabled { if let Some((owner, repo)) = get_gitedit_project(&repo_root) { gitedit_session_hash = gitedit_sessions_hash(&owner, &repo, &sessions); } gitedit_sessions = sessions.clone(); } if unhash_enabled { unhash_sessions = sessions; } } } // Append author note if provided let mut full_message = if let Some(note) = author_message { format!("{}\n\nauthor: {}", message, note) } else { message }; if let Some(hash) = gitedit_session_hash.as_deref() { full_message = format!("{}\n\ngitedit.dev/{}", full_message, hash); } if unhash_enabled { if let Some(unhash_hash) = capture_unhash_bundle( &repo_root, &diff, Some(&status), Some(&review), Some(&review_model_label), Some(review_reviewer_label), review_instructions.as_deref(), session_context.as_deref(), Some(&unhash_sessions), gitedit_session_hash.as_deref(), &full_message, author_message, include_context, ) { full_message = format!("{}\n\nunhash.sh/{}", full_message, unhash_hash); } } // Show the message println!("Commit message:"); println!("────────────────────────────────────────"); println!("{}", full_message); println!("────────────────────────────────────────\n"); // Check if docs need updating (reminder for AI assistant) let docs_dir = repo_root.join(".ai/docs"); if docs_dir.exists() { let has_new_commands = diff.contains("pub enum Commands") || diff.contains("Subcommand") || diff.contains("#[command("); let has_new_features = diff.contains("pub fn run") || diff.contains("pub async fn") || diff.lines().any(|l| l.starts_with("+pub mod")); if has_new_commands || has_new_features { println!("📝 Docs may need updating (.ai/docs/)"); } } ensure_no_internal_staged(&repo_root)?; ensure_no_unwanted_staged(&repo_root)?; gitignore_policy::enforce_staged_policy(&repo_root)?; // Commit let paragraphs = split_paragraphs(&full_message); let mut args = vec!["commit"]; for p in ¶graphs { args.push("-m"); args.push(p); } git_run(&args)?; println!("✓ Committed"); if let Ok(commit_sha) = git_capture_in(&repo_root, &["rev-parse", "HEAD"]) { let branch = git_capture_in(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()); ai::log_commit_review( &repo_root, commit_sha.trim(), branch.trim(), &full_message, &review_model_label, review_reviewer_label, review.issues_found, &review.issues, review.summary.as_deref(), review.timed_out, context_chars, ); } else { debug!("failed to capture commit SHA for review log"); } let review_summary = ai::CommitReviewSummary { model: review_model_label.clone(), reviewer: review_reviewer_label.to_string(), issues_found: review.issues_found, issues: review.issues.clone(), summary: review.summary.clone(), timed_out: review.timed_out, }; let context_len = if context_chars > 0 { Some(context_chars) } else { None }; log_commit_event_for_repo( &repo_root, &full_message, "commitWithCheck", Some(review_summary), context_len, ); // Record review issues as project-scoped todos so they cannot be ignored. // This is best-effort: never block commits on todo persistence. let mut review_todo_ids: Vec<String> = Vec::new(); let committed_sha = git_capture_in(&repo_root, &["rev-parse", "HEAD"]) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); if let Some(commit_sha) = committed_sha.as_deref() { if !env_flag("FLOW_REVIEW_ISSUES_TODOS_DISABLE") { if review.issues_found && !review.issues.is_empty() { match todo::record_review_issues_as_todos( &repo_root, commit_sha, &review.issues, review.summary.as_deref(), &review_model_label, ) { Ok(ids) => { if !ids.is_empty() { println!("Added {} review issue todo(s) to .ai/todos", ids.len()); } review_todo_ids.extend(ids); } Err(err) => println!("⚠ Failed to record review issues as todos: {}", err), } } if review.timed_out { let issue = format!( "Re-run review: review timed out for commit {}", short_sha(commit_sha) ); match todo::record_review_issues_as_todos( &repo_root, commit_sha, &vec![issue], review.summary.as_deref(), &review_model_label, ) { Ok(ids) => { if !ids.is_empty() { println!("Added {} review todo(s) to .ai/todos", ids.len()); } review_todo_ids.extend(ids); } Err(err) => println!("⚠ Failed to record review timeout todo: {}", err), } } } } // Record review outputs as ephemeral beads in beads_rust record_review_outputs_to_beads_rust( &repo_root, &review, review_reviewer_label, &review_model_label, committed_sha.as_deref(), &review_run_id, ); let review_report_path = match write_commit_review_markdown_report( &repo_root, &review, review_reviewer_label, &review_model_label, committed_sha.as_deref(), &full_message, &review_run_id, &review_todo_ids, ) { Ok(path) => Some(path), Err(err) => { println!("⚠ Failed to write review report: {}", err); None } }; if queue_enabled { match queue_commit_for_review( &repo_root, &full_message, Some(&review), Some(&review_model_label), Some(review_reviewer_label), review_todo_ids, ) { Ok(sha) => { print_queue_instructions(&repo_root, &sha); if queue.open_review { open_review_in_rise(&repo_root, &sha); } } Err(err) => println!("⚠ Failed to queue commit for review: {}", err), } } // Push if requested let mut pushed = false; if push { let push_remote = config::preferred_git_remote_for_repo(&repo_root); let push_branch = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); print!("Pushing... "); io::stdout().flush()?; match git_push_try(&push_remote, &push_branch) { PushResult::Success => { println!("done"); pushed = true; } PushResult::NoRemoteRepo => { println!("skipped (no remote repo)"); } PushResult::RemoteAhead => { println!("failed (remote ahead)"); print!("Pulling with rebase... "); io::stdout().flush()?; match git_pull_rebase_try(&push_remote, &push_branch) { Ok(_) => { println!("done"); print!("Pushing... "); io::stdout().flush()?; git_push_run(&push_remote, &push_branch)?; println!("done"); pushed = true; } Err(_) => { println!("conflict!"); println!(); println!("Rebase conflict detected. Resolve manually:"); println!(" 1. Fix conflicts in the listed files"); println!(" 2. git add <files>"); println!(" 3. git rebase --continue"); println!(" 4. git push"); println!(); println!("Or abort with: git rebase --abort"); println!("\nnotify: Rebase conflict - manual resolution required"); bail!("Rebase conflict - manual resolution required"); } } } } } // Record undo action (use full_message which contains the commit message) record_undo_action(&repo_root, pushed, Some(&full_message)); cleanup_staged_snapshot(&staged_snapshot); // Advance checkpoint for all commit paths so syncs only include new exchanges. save_commit_checkpoint_for_repo(&repo_root); // Sync to gitedit if enabled let should_sync = if force_gitedit { gitedit_enabled } else { push && gitedit_enabled }; if should_sync { // Build review data for gitedit let review_data = GitEditReviewData { diff: Some(diff.clone()), issues_found: review.issues_found, issues: review.issues.clone(), summary: review.summary.clone(), reviewer: Some(review_reviewer_label.to_string()), }; sync_to_gitedit( &repo_root, "commit_with_check", &gitedit_sessions, gitedit_session_hash.as_deref(), Some(&review_data), ); // Also sync to myflow if enabled if myflow_mirror_enabled(&repo_root) { sync_to_myflow( &repo_root, "commit_with_check", &gitedit_sessions, pending_sync_window.as_ref(), Some(&review_data), Some(&skill_gate_report), ); } } else if myflow_mirror_enabled(&repo_root) { // myflow sync even when gitedit sync is skipped let review_data = GitEditReviewData { diff: Some(diff.clone()), issues_found: review.issues_found, issues: review.issues.clone(), summary: review.summary.clone(), reviewer: Some(review_reviewer_label.to_string()), }; // Get AI sessions for myflow even if gitedit didn't collect them let (myflow_sessions, myflow_window) = collect_sync_sessions_for_commit_with_window(&repo_root); sync_to_myflow( &repo_root, "commit_with_check", &myflow_sessions, Some(&myflow_window), Some(&review_data), Some(&skill_gate_report), ); } if let Some(path) = review_report_path.as_ref() { println!("Review report: {}", path.display()); println!("Run: f fix {}", path.display()); } Ok(()) } /// Write a JSON-RPC message to a writer (newline-delimited). fn codex_write_msg(writer: &mut dyn Write, msg: &serde_json::Value) -> Result<()> { let mut line = serde_json::to_string(msg)?; line.push('\n'); writer.write_all(line.as_bytes())?; writer.flush()?; Ok(()) } enum CodexAppServerEvent { Line(String), ReadError(String), Closed, } enum CodexReadOutcome { Message(serde_json::Value), TimedOut, } fn codex_read_next_message( rx: &std::sync::mpsc::Receiver<CodexAppServerEvent>, deadline: std::time::Instant, ) -> Result<CodexReadOutcome> { use std::cmp; use std::sync::mpsc::RecvTimeoutError; use std::time::Instant; loop { let now = Instant::now(); if now >= deadline { return Ok(CodexReadOutcome::TimedOut); } let wait = cmp::min( Duration::from_millis(250), deadline.saturating_duration_since(now), ); match rx.recv_timeout(wait) { Ok(CodexAppServerEvent::Line(line)) => { if line.trim().is_empty() { continue; } let msg: serde_json::Value = serde_json::from_str(&line) .with_context(|| format!("invalid JSON from codex: {}", line))?; return Ok(CodexReadOutcome::Message(msg)); } Ok(CodexAppServerEvent::ReadError(err)) => bail!("failed to read from codex: {}", err), Ok(CodexAppServerEvent::Closed) => bail!("codex app-server closed stdout unexpectedly"), Err(RecvTimeoutError::Timeout) => continue, Err(RecvTimeoutError::Disconnected) => bail!("codex app-server reader disconnected"), } } } /// Read lines until a JSON-RPC response with the expected id arrives. fn codex_read_response( rx: &std::sync::mpsc::Receiver<CodexAppServerEvent>, expected_id: u64, deadline: std::time::Instant, ) -> Result<serde_json::Value> { loop { let msg = match codex_read_next_message(rx, deadline)? { CodexReadOutcome::Message(msg) => msg, CodexReadOutcome::TimedOut => bail!("codex app-server response timed out"), }; if msg.get("id").and_then(|id| id.as_u64()) == Some(expected_id) { if let Some(err) = msg.get("error") { bail!( "codex error: {}", err.get("message") .and_then(|m| m.as_str()) .unwrap_or("unknown error") ); } return Ok(msg); } } } fn codex_read_response_with_notifications<F>( rx: &std::sync::mpsc::Receiver<CodexAppServerEvent>, expected_id: u64, deadline: std::time::Instant, mut on_notification: F, ) -> Result<serde_json::Value> where F: FnMut(&serde_json::Value), { loop { let msg = match codex_read_next_message(rx, deadline)? { CodexReadOutcome::Message(msg) => msg, CodexReadOutcome::TimedOut => bail!("codex app-server response timed out"), }; if msg.get("method").is_some() && msg.get("id").is_none() { on_notification(&msg); } if msg.get("id").and_then(|id| id.as_u64()) == Some(expected_id) { if let Some(err) = msg.get("error") { bail!( "codex error: {}", err.get("message") .and_then(|m| m.as_str()) .unwrap_or("unknown error") ); } return Ok(msg); } } } fn openrouter_review_should_use_codex() -> bool { // Default: true (use Codex /review when available) to improve commit-check quality. // Allow opt-out for cases where the user explicitly wants OpenRouter. match env::var("FLOW_OPENROUTER_REVIEW_USE_CODEX") { Ok(v) if v.trim() == "0" || v.trim().eq_ignore_ascii_case("false") => false, _ => true, } } fn beads_rust_history_dir(repo_root: &Path) -> PathBuf { repo_root .join(".beads") .join(".br_history") .join("flow_commit_reviews") } fn beads_rust_beads_dir(repo_root: &Path) -> PathBuf { repo_root.join(".beads") } fn flow_commit_reports_dir() -> Option<PathBuf> { if let Ok(value) = env::var("FLOW_COMMIT_REPORT_DIR") { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } dirs::home_dir().map(|home| home.join(".flow").join("commits")) } fn write_commit_review_markdown_report( repo_root: &Path, review: &ReviewResult, reviewer: &str, model_label: &str, committed_sha: Option<&str>, commit_message: &str, review_run_id: &str, review_todo_ids: &[String], ) -> Result<PathBuf> { let Some(report_dir) = flow_commit_reports_dir() else { bail!("could not resolve commit report directory"); }; fs::create_dir_all(&report_dir)?; let project_name = flow_project_name(repo_root); let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let sha_short = committed_sha.map(short_sha).unwrap_or("unknown"); let stamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); let file_name = format!( "{}-{}-{}-{}.md", safe_label_value(&project_name), safe_label_value(&branch), sha_short, stamp ); let path = report_dir.join(file_name); let mut md = String::new(); md.push_str("# Flow Commit Review\n\n"); md.push_str("- Generated: "); md.push_str(&chrono::Utc::now().to_rfc3339()); md.push_str("\n- Project: "); md.push_str(&project_name); md.push_str("\n- Repo Root: "); md.push_str(&repo_root.display().to_string()); md.push_str("\n- Branch: "); md.push_str(&branch); md.push_str("\n- Commit: "); md.push_str(sha_short); md.push_str("\n- Reviewer: "); md.push_str(reviewer); md.push_str("\n- Model: "); md.push_str(model_label); md.push_str("\n- Review Run ID: "); md.push_str(review_run_id); md.push_str("\n- Timed Out: "); md.push_str(if review.timed_out { "yes" } else { "no" }); md.push_str("\n\n## Commit Message\n\n```text\n"); md.push_str(commit_message.trim()); md.push_str("\n```\n"); if let Some(summary) = review .summary .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { md.push_str("\n## Summary\n\n"); md.push_str(summary); md.push('\n'); } md.push_str("\n## Issues\n\n"); if review.issues.is_empty() { md.push_str("0. (none)\n"); } else { for (idx, issue) in review.issues.iter().enumerate() { md.push_str(&(idx + 1).to_string()); md.push_str(". "); md.push_str(issue.trim()); md.push('\n'); } } md.push_str("\n## Future Tasks\n\n"); if review.future_tasks.is_empty() { md.push_str("0. (none)\n"); } else { for (idx, task) in review.future_tasks.iter().enumerate() { md.push_str(&(idx + 1).to_string()); md.push_str(". "); md.push_str(task.trim()); md.push('\n'); } } if !review_todo_ids.is_empty() { md.push_str("\n## Todo IDs\n\n"); for todo_id in review_todo_ids { md.push_str("- "); md.push_str(todo_id.trim()); md.push('\n'); } } md.push_str("\n## Next Step\n\n```bash\nf fix "); md.push_str(&path.display().to_string()); md.push_str("\n```\n"); fs::write(&path, md).with_context(|| format!("write {}", path.display()))?; Ok(path) } fn write_beads_commit_review_record( repo_root: &Path, reviewer: &str, model_label: &str, review: &ReviewResult, commit_message: Option<&str>, ) -> Result<()> { #[derive(Serialize)] struct BeadsCommitReviewRecord<'a> { timestamp: String, repo_root: String, repo_name: String, branch: String, reviewer: &'a str, model: &'a str, issues_found: bool, issues: Vec<String>, future_tasks: Vec<String>, summary: Option<String>, commit_message: Option<String>, } let dir = beads_rust_history_dir(repo_root); fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; let repo_name = repo_root .file_name() .and_then(|n| n.to_str()) .unwrap_or("repo") .to_string(); let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let ts = chrono::Utc::now().to_rfc3339(); let stamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); let safe_repo = repo_name .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect::<String>(); let safe_branch = branch .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect::<String>(); let path = dir.join(format!( "review.{}.{}.{}.json", stamp, safe_repo, safe_branch )); let record = BeadsCommitReviewRecord { timestamp: ts, repo_root: repo_root.display().to_string(), repo_name, branch, reviewer, model: model_label, issues_found: review.issues_found, issues: review.issues.clone(), future_tasks: review.future_tasks.clone(), summary: review.summary.clone(), commit_message: commit_message.map(|s| s.to_string()), }; let json = serde_json::to_string_pretty(&record)?; fs::write(&path, json)?; Ok(()) } /// Run Codex app-server `review/start` to review staged changes. /// /// Spawns `codex app-server` over stdio JSON-RPC, sends initialize handshake, /// creates a thread, and uses the built-in `review/start` method which is /// optimized for code review (structured findings, confidence scores, etc.). fn run_codex_review( _diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, workdir: &std::path::Path, model: CodexModel, ) -> Result<ReviewResult> { let max_attempts = commit_with_check_review_retries() + 1; // retries + initial attempt let mut last_timeout_secs = 0u64; for attempt in 1..=max_attempts { match run_codex_review_once(_diff, session_context, review_instructions, workdir, model) { Ok(result) if result.timed_out && attempt < max_attempts => { last_timeout_secs = commit_with_check_timeout_secs(); let backoff_secs = commit_with_check_retry_backoff_secs(attempt); println!( "⚠ Review timed out after {}s, retrying ({}/{}) in {}s...", last_timeout_secs, attempt, max_attempts, backoff_secs ); std::thread::sleep(Duration::from_secs(backoff_secs)); continue; } other => return other, } } // Should not reach here, but just in case Ok(ReviewResult { issues_found: false, issues: Vec::new(), summary: Some(format!( "Codex review timed out after {}s (exhausted {} attempts)", last_timeout_secs, max_attempts )), future_tasks: Vec::new(), timed_out: true, quality: None, }) } fn run_codex_review_once( _diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, workdir: &std::path::Path, model: CodexModel, ) -> Result<ReviewResult> { use std::io::{BufRead, BufReader}; use std::sync::mpsc; use std::time::Instant; let timeout = Duration::from_secs(commit_with_check_timeout_secs()); let mut developer_instructions = String::new(); if let Some(instructions) = review_instructions { if !instructions.trim().is_empty() { developer_instructions.push_str("Additional review instructions:\n"); developer_instructions.push_str(instructions.trim()); developer_instructions.push_str("\n\n"); } } if let Some(ctx) = session_context { if !ctx.trim().is_empty() { developer_instructions.push_str("Context:\n"); developer_instructions.push_str(ctx.trim()); developer_instructions.push_str("\n\n"); } } let codex_bin = configured_codex_bin_for_workdir(workdir); // Spawn codex app-server (JSON-RPC over stdio) let mut child = Command::new(&codex_bin) .arg("app-server") .current_dir(workdir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to run codex app-server - is codex installed?")?; let mut stdin = child.stdin.take().context("missing stdin")?; let stdout = child.stdout.take().context("missing stdout")?; let (line_tx, line_rx) = mpsc::channel::<CodexAppServerEvent>(); let reader_handle = std::thread::spawn(move || { let reader = BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) => { if line_tx.send(CodexAppServerEvent::Line(line)).is_err() { return; } } Err(err) => { let _ = line_tx.send(CodexAppServerEvent::ReadError(err.to_string())); return; } } } let _ = line_tx.send(CodexAppServerEvent::Closed); }); let handshake_deadline = Instant::now() + Duration::from_secs(15); // Step 1: Initialize handshake codex_write_msg( &mut stdin, &json!({ "id": 1, "method": "initialize", "params": { "clientInfo": { "name": "flow", "title": "Flow CLI", "version": "0.1.0" }, "capabilities": { "experimentalApi": true } } }), )?; let _init_resp = codex_read_response(&line_rx, 1, handshake_deadline) .context("codex app-server did not respond to initialize")?; // Step 2: Send initialized notification codex_write_msg(&mut stdin, &json!({ "method": "initialized" }))?; // Step 3: Start a thread let op_deadline = Instant::now() + timeout; codex_write_msg( &mut stdin, &json!({ "id": 2, "method": "thread/start", "params": { "cwd": workdir.to_string_lossy(), "approvalPolicy": "never", "sandbox": "read-only", "model": model.as_codex_arg(), "developerInstructions": if developer_instructions.trim().is_empty() { serde_json::Value::Null } else { json!(developer_instructions.trim()) } } }), )?; let thread_resp = codex_read_response(&line_rx, 2, op_deadline)?; let thread_id = thread_resp .pointer("/result/threadId") .or_else(|| thread_resp.pointer("/result/thread/id")) .and_then(|v| v.as_str()) .context("failed to get threadId from codex")? .to_string(); // Step 4: Start review using review/start with appropriate target let target = json!({ "type": "uncommittedChanges" }); codex_write_msg( &mut stdin, &json!({ "id": 3, "method": "review/start", "params": { "threadId": thread_id, "target": target, "delivery": "inline" } }), )?; let _review_resp = codex_read_response(&line_rx, 3, op_deadline)?; // Step 5: Collect streaming events until we see the ExitedReviewMode item. let mut review_text: Option<String> = None; let mut timed_out = false; let review_start = Instant::now(); let hard_cap = Duration::from_secs(commit_with_check_timeout_secs().saturating_mul(3)); let hard_deadline = review_start + hard_cap; let mut idle_deadline = review_start + timeout; loop { let next_deadline = std::cmp::min(idle_deadline, hard_deadline); let msg = match codex_read_next_message(&line_rx, next_deadline)? { CodexReadOutcome::Message(msg) => msg, CodexReadOutcome::TimedOut => { timed_out = true; break; } }; idle_deadline = Instant::now() + timeout; let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); match method { "item/completed" => { let thread_id_msg = msg.pointer("/params/threadId").and_then(|v| v.as_str()); if thread_id_msg != Some(thread_id.as_str()) { continue; } let item_type = msg.pointer("/params/item/type").and_then(|v| v.as_str()); if item_type == Some("exitedReviewMode") { if let Some(text) = msg.pointer("/params/item/review").and_then(|v| v.as_str()) { review_text = Some(text.to_string()); break; } } } "review/completed" => { let thread_id_msg = msg.pointer("/params/threadId").and_then(|v| v.as_str()); if thread_id_msg != Some(thread_id.as_str()) { continue; } if let Some(text) = msg .pointer("/params/review") .or_else(|| msg.pointer("/params/item/review")) .and_then(|v| v.as_str()) { review_text = Some(text.to_string()); break; } } _ => {} } } let review_text = review_text.unwrap_or_default(); if timed_out { // Best-effort cleanup let _ = codex_write_msg( &mut stdin, &json!({ "id": 4, "method": "thread/archive", "params": { "threadId": thread_id } }), ); drop(stdin); let _ = child.kill(); let _ = child.wait(); let _ = reader_handle.join(); return Ok(ReviewResult { issues_found: false, issues: Vec::new(), summary: Some(format!( "Codex review timed out after {}s", review_start.elapsed().as_secs() )), future_tasks: Vec::new(), timed_out: true, quality: None, }); } let result = review_text.trim().to_string(); if !result.is_empty() { println!("{}", result); } // Codex review output is plain text. Convert it into the structured JSON // format expected by the rest of Flow via a small follow-up turn. let mut json_output = String::new(); let conversion_deadline = Instant::now() + Duration::from_secs(60); let conversion_prompt = format!( "Convert the following code review into JSON ONLY with this exact schema: \ {{\"issues_found\": true/false, \"issues\": [\"...\"], \"summary\": \"...\", \"future_tasks\": [\"...\"]}}.\n\ Rules:\n\ - Put concrete, actionable problems in issues (include file paths/line hints when present).\n\ - future_tasks are optional follow-up improvements (max 3), not duplicates of issues.\n\ - If review contains no concrete issues, set issues_found=false and issues=[].\n\ Review:\n{}", result ); codex_write_msg( &mut stdin, &json!({ "id": 5, "method": "turn/start", "params": { "threadId": thread_id, "cwd": workdir.to_string_lossy(), "approvalPolicy": "never", "sandboxPolicy": { "type": "readOnly" }, "input": [{ "type": "text", "text": conversion_prompt }] } }), )?; let _turn_resp = codex_read_response_with_notifications(&line_rx, 5, conversion_deadline, |msg| { let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); if method != "item/agentMessage/delta" { return; } let thread_id_msg = msg.pointer("/params/threadId").and_then(|v| v.as_str()); if thread_id_msg != Some(thread_id.as_str()) { return; } if let Some(delta) = msg.pointer("/params/delta").and_then(|v| v.as_str()) { json_output.push_str(delta); } })?; // Now stream until turn/completed for this thread, collecting agent deltas. loop { let msg = match codex_read_next_message(&line_rx, conversion_deadline)? { CodexReadOutcome::Message(msg) => msg, CodexReadOutcome::TimedOut => break, }; let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); match method { "item/agentMessage/delta" => { let thread_id_msg = msg.pointer("/params/threadId").and_then(|v| v.as_str()); if thread_id_msg != Some(thread_id.as_str()) { continue; } if let Some(delta) = msg.pointer("/params/delta").and_then(|v| v.as_str()) { json_output.push_str(delta); } } "turn/completed" => { let thread_id_msg = msg.pointer("/params/threadId").and_then(|v| v.as_str()); if thread_id_msg == Some(thread_id.as_str()) { break; } } _ => {} } } let json_output = json_output.trim().to_string(); let mut review_json = parse_review_json(&json_output); let future_tasks = review_json .as_ref() .map(|parsed| normalize_future_tasks(&parsed.future_tasks)) .unwrap_or_default(); let summary = review_json.as_ref().and_then(|r| r.summary.clone()); let quality = review_json.as_mut().and_then(|r| r.quality.take()); let (issues_found, issues) = if let Some(ref parsed) = review_json { (parsed.issues_found, parsed.issues.clone()) } else if result.is_empty() { (false, Vec::new()) } else { // Fallback: parse bullet items from Codex's rendered review text. let mut issues = Vec::new(); for line in result.lines() { let trimmed = line.trim_start(); if let Some(rest) = trimmed.strip_prefix("- ") { let t = rest.trim(); if !t.is_empty() { issues.push(t.to_string()); } } } (!issues.is_empty(), issues) }; // Cleanup: archive thread and kill process let _ = codex_write_msg( &mut stdin, &json!({ "id": 4, "method": "thread/archive", "params": { "threadId": thread_id } }), ); drop(stdin); let _ = child.kill(); let _ = child.wait(); let _ = reader_handle.join(); Ok(ReviewResult { issues_found, issues, summary, future_tasks, timed_out: false, quality, }) } pub(crate) fn configured_codex_bin_for_workdir(workdir: &Path) -> String { if let Ok(value) = env::var("CODEX_BIN") { let trimmed = value.trim(); if !trimmed.is_empty() { return normalize_codex_bin_value(trimmed); } } let mut roots: Vec<PathBuf> = vec![workdir.to_path_buf()]; if let Ok(repo_root) = git_capture_in(workdir, &["rev-parse", "--show-toplevel"]) { let trimmed = repo_root.trim(); if !trimmed.is_empty() { let root = PathBuf::from(trimmed); if !roots.iter().any(|r| r == &root) { roots.push(root); } } } for root in roots { let cfg_path = root.join("flow.toml"); if !cfg_path.exists() { continue; } if let Ok(cfg) = config::load(&cfg_path) { if let Some(bin) = cfg.options.codex_bin { let trimmed = bin.trim(); if !trimmed.is_empty() { return normalize_codex_bin_value(trimmed); } } } } let global_cfg = config::default_config_path(); if global_cfg.exists() { if let Ok(cfg) = config::load(&global_cfg) { if let Some(bin) = cfg.options.codex_bin { let trimmed = bin.trim(); if !trimmed.is_empty() { return normalize_codex_bin_value(trimmed); } } } } "codex".to_string() } fn normalize_codex_bin_value(raw: &str) -> String { let trimmed = raw.trim(); if trimmed.is_empty() { return String::new(); } if trimmed.starts_with('~') || trimmed.starts_with('$') || trimmed.starts_with('.') || trimmed.starts_with('/') || trimmed.contains(std::path::MAIN_SEPARATOR) { return config::expand_path(trimmed).to_string_lossy().into_owned(); } trimmed.to_string() } fn normalize_review_url(url: &str) -> String { let trimmed = url.trim().trim_end_matches('/'); if trimmed.ends_with("/review") { trimmed.to_string() } else { format!("{}/review", trimmed) } } fn run_remote_claude_review( diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, model: ClaudeModel, ) -> Result<ReviewResult> { let url = match commit_with_check_review_url() { Some(url) => url, None => bail!("remote review URL not configured"), }; let review_url = normalize_review_url(&url); let payload = RemoteReviewRequest { diff: diff.to_string(), context: session_context.map(|value| value.to_string()), model: model.as_claude_arg().to_string(), review_instructions: review_instructions.map(|v| v.to_string()), }; let client = crate::http_client::blocking_with_timeout(Duration::from_secs( commit_with_check_timeout_secs(), )) .context("failed to create HTTP client for remote review")?; let mut request = client.post(&review_url).json(&payload); if let Some(token) = commit_with_check_review_token() { request = request.bearer_auth(token); } let response = request .send() .context("failed to send remote review request")?; if !response.status().is_success() { if response.status() == StatusCode::UNAUTHORIZED { bail!("remote review unauthorized. Run `f auth` to login."); } if response.status() == StatusCode::PAYMENT_REQUIRED { bail!("remote review requires an active subscription. Visit myflow to subscribe."); } bail!("remote review failed: HTTP {}", response.status()); } let payload: RemoteReviewResponse = response .json() .context("failed to parse remote review response")?; if !payload.stderr.trim().is_empty() { debug!(stderr = payload.stderr.as_str(), "remote claude stderr"); } let result = payload.output; let mut review_json = parse_review_json(&result); let future_tasks = review_json .as_ref() .map(|parsed| normalize_future_tasks(&parsed.future_tasks)) .unwrap_or_default(); let summary = review_json.as_ref().and_then(|r| r.summary.clone()); let quality = review_json.as_mut().and_then(|r| r.quality.take()); let (issues_found, issues) = if let Some(ref parsed) = review_json { if let Some(summary) = parsed.summary.as_ref() { debug!(summary = summary.as_str(), "remote claude review summary"); } (parsed.issues_found, parsed.issues.clone()) } else if result.trim().is_empty() { (false, Vec::new()) } else { debug!( review_output = result.as_str(), "remote claude review output" ); let lowered = result.to_lowercase(); let has_issues = lowered.contains("bug") || lowered.contains("issue") || lowered.contains("problem") || lowered.contains("error") || lowered.contains("vulnerability") || lowered.contains("performance issue") || lowered.contains("memory leak"); (has_issues, Vec::new()) }; Ok(ReviewResult { issues_found, issues, summary, future_tasks, timed_out: false, quality, }) } /// Run Claude Code SDK to review staged changes for bugs and performance issues. fn run_claude_review( diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, workdir: &std::path::Path, model: ClaudeModel, ) -> Result<ReviewResult> { if commit_with_check_review_url().is_some() { match run_remote_claude_review(diff, session_context, review_instructions, model) { Ok(review) => return Ok(review), Err(err) => { println!("⚠ Remote review failed: {}", err); println!(" Falling back to local Claude review..."); } } } let local_review = (|| -> Result<ReviewResult> { use std::io::{BufRead, BufReader}; use std::sync::mpsc; use std::time::Instant; let (diff_for_prompt, _truncated) = truncate_diff(diff); // Build compact review prompt optimized for speed/cost let mut prompt = String::from( "Review diff for bugs, security, perf issues. Return JSON: {\"issues_found\":bool,\"issues\":[\"...\"],\"summary\":\"...\",\"future_tasks\":[\"...\"]}. future_tasks are optional follow-up improvements or optimizations (max 3), actionable, and not duplicates of issues; use [] if none.\n", ); // Add quality assessment instructions if quality gates are enabled let quality_config = config::load_or_default(workdir.join("flow.toml")) .commit .and_then(|c| c.quality) .unwrap_or_default(); let quality_mode = quality_config.mode.as_deref().unwrap_or("warn"); if quality_mode != "off" { prompt.push_str( "\nAdditionally, analyze the diff for quality assessment. Add a \"quality\" object to your JSON response:\n\ {\"quality\":{\"features_touched\":[{\"name\":\"kebab-name\",\"action\":\"added|modified|fixed\",\"description\":\"one sentence\",\"files_changed\":[\"...\"],\"has_tests\":bool,\"test_files\":[\"...\"],\"doc_current\":bool}],\ \"new_features\":[{\"name\":\"kebab-name\",\"description\":\"one sentence\",\"files\":[\"...\"],\"doc_content\":\"# Title\\n\\nDescription...\"}],\ \"test_coverage\":\"full|partial|none\",\"doc_coverage\":\"full|partial|none\",\"gate_pass\":bool,\"gate_failures\":[\"...\"]}}\n\ A \"feature\" = a user-visible capability, API endpoint, or CLI command. Name features in kebab-case. \ gate_pass is false if new features lack tests or docs. gate_failures lists specific reasons.\n", ); // Add features context (existing documented features) if available let features_ctx = crate::features::features_context_for_review( workdir, &changed_files_from_diff(diff), ); if !features_ctx.is_empty() { prompt.push_str(&features_ctx); } } // Add custom review instructions if provided if let Some(instructions) = review_instructions { prompt.push_str(&format!( "\nAdditional review instructions:\n{}\n", instructions )); } // Add session context if provided if let Some(context) = session_context { prompt.push_str(&format!("\nContext:\n{}\n", context)); } prompt.push_str(&format!("```diff\n{}\n```", diff_for_prompt)); // Use claude CLI with print mode, piping prompt via stdin to avoid arg length limits let model_arg = model.as_claude_arg(); let mut child = Command::new("claude") .args(["-p", "--model", model_arg]) .current_dir(workdir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to run claude - is Claude Code SDK installed?")?; // Write prompt to stdin and explicitly close it { let mut stdin = child.stdin.take().context("failed to get stdin")?; stdin .write_all(prompt.as_bytes()) .context("failed to write prompt to claude stdin")?; stdin.flush().context("failed to flush stdin")?; drop(stdin); // Explicitly close stdin to signal EOF } let stdout = child.stdout.take().unwrap(); let stderr = child.stderr.take().unwrap(); let (tx, rx) = mpsc::channel(); let start = Instant::now(); let tx_stdout = tx.clone(); let reader_handle = std::thread::spawn(move || { let reader = BufReader::new(stdout); for line in reader.lines().flatten() { let _ = tx_stdout.send(ReviewEvent::Line(line)); } let _ = tx_stdout.send(ReviewEvent::StdoutDone); }); let tx_stderr = tx.clone(); let stderr_handle = std::thread::spawn(move || { let reader = BufReader::new(stderr); for line in reader.lines().flatten() { let _ = tx_stderr.send(ReviewEvent::StderrLine(line)); } let _ = tx_stderr.send(ReviewEvent::StderrDone); }); let mut output_lines = Vec::new(); let mut stderr_lines = Vec::new(); let mut last_progress = Instant::now(); let timeout = Duration::from_secs(commit_with_check_timeout_secs()); let mut deadline = Instant::now() + timeout; let mut timed_out = false; let mut done_count = 0; loop { match rx.recv_timeout(Duration::from_secs(2)) { Ok(ReviewEvent::Line(line)) => { println!("{}", line); output_lines.push(line); last_progress = Instant::now(); } Ok(ReviewEvent::StderrLine(line)) => { if !line.trim().is_empty() { println!("claude: {}", line); } stderr_lines.push(line); } Ok(ReviewEvent::StdoutDone) | Ok(ReviewEvent::StderrDone) => { done_count += 1; if done_count >= 2 { break; } } Err(mpsc::RecvTimeoutError::Timeout) => { if last_progress.elapsed() >= Duration::from_secs(10) { println!( "Waiting on Claude review... ({}s elapsed, no output yet)", start.elapsed().as_secs() ); last_progress = Instant::now(); } if Instant::now() >= deadline { if prompt_yes_no( "Claude review is taking longer than expected. Keep waiting?", )? { deadline = Instant::now() + timeout; } else { timed_out = true; let _ = child.kill(); break; } } } Err(mpsc::RecvTimeoutError::Disconnected) => break, } } let _ = reader_handle.join(); let _ = stderr_handle.join(); let status = child.wait()?; let stderr_output = stderr_lines.join("\n"); if timed_out { if !stderr_output.trim().is_empty() { println!("{}", stderr_output.trim_end()); } return Ok(ReviewResult { issues_found: false, issues: Vec::new(), summary: Some(format!( "Claude review timed out after {}s", timeout.as_secs() )), future_tasks: Vec::new(), timed_out: true, quality: None, }); } if !status.success() { if !stderr_output.trim().is_empty() { println!("{}", stderr_output.trim_end()); } println!("\nnotify: Claude review failed"); bail!("Claude review failed"); } let result = output_lines.join("\n"); let mut review_json = parse_review_json(&result); let future_tasks = review_json .as_ref() .map(|parsed| normalize_future_tasks(&parsed.future_tasks)) .unwrap_or_default(); let summary = review_json.as_ref().and_then(|r| r.summary.clone()); let quality = review_json.as_mut().and_then(|r| r.quality.take()); let (issues_found, issues) = if let Some(ref parsed) = review_json { if let Some(summary) = parsed.summary.as_ref() { debug!(summary = summary.as_str(), "claude review summary"); } (parsed.issues_found, parsed.issues.clone()) } else if result.trim().is_empty() { (false, Vec::new()) } else { debug!(review_output = result.as_str(), "claude review output"); let lowered = result.to_lowercase(); let has_issues = lowered.contains("bug") || lowered.contains("issue") || lowered.contains("problem") || lowered.contains("error") || lowered.contains("vulnerability") || lowered.contains("performance issue") || lowered.contains("memory leak"); (has_issues, Vec::new()) }; Ok(ReviewResult { issues_found, issues, summary, future_tasks, timed_out: false, quality, }) })(); match local_review { Ok(review) => Ok(review), Err(err) => { println!("⚠ Local Claude review failed: {}", err); println!(" Proceeding without review."); Ok(ReviewResult { issues_found: false, issues: Vec::new(), summary: Some(format!("Claude review failed: {}", err)), future_tasks: Vec::new(), timed_out: false, quality: None, }) } } } /// Run opencode to review staged changes for bugs and performance issues. fn run_opencode_review( diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, workdir: &std::path::Path, model: &str, ) -> Result<ReviewResult> { use std::io::{BufRead, BufReader, Write}; let (diff_for_prompt, _truncated) = truncate_diff(diff); // Write diff to a temp file in the working directory to avoid /tmp permission issues let diff_file = workdir.join(".flow_diff_review.tmp"); { let mut f = std::fs::File::create(&diff_file).context("failed to create temp diff file")?; f.write_all(diff_for_prompt.as_bytes()) .context("failed to write temp diff file")?; } // Build review prompt - explicitly say to output to stdout only let mut prompt = String::from( "Review the attached git diff file for bugs, security issues, and performance problems. \ Output ONLY a JSON object to stdout with this exact format (do not write any files): \ {\"issues_found\": true/false, \"issues\": [\"issue 1\", \"issue 2\"], \"summary\": \"brief summary\", \"future_tasks\": [\"optional follow-up\"]}. \ future_tasks are optional improvements/optimizations (max 3), actionable, and not duplicates of issues; use [] if none.", ); if let Some(instructions) = review_instructions { prompt.push_str(&format!( "\n\nAdditional review instructions:\n{}", instructions )); } if let Some(context) = session_context { prompt.push_str(&format!("\n\nContext:\n{}", context)); } // Run opencode with the diff as an attached file let mut child = Command::new("opencode") .args([ "run", "--model", model, "-f", diff_file.to_str().unwrap(), &prompt, ]) .current_dir(workdir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to run opencode - is it installed?")?; let stdout = child.stdout.take().unwrap(); let stderr = child.stderr.take().unwrap(); // Read output with timeout let reader = BufReader::new(stdout); let mut output_lines = Vec::new(); for line in reader.lines().flatten() { print!("{}\n", line); output_lines.push(line); } // Also capture stderr let stderr_reader = BufReader::new(stderr); for line in stderr_reader.lines().flatten() { debug!("opencode stderr: {}", line); } let status = child.wait()?; if !status.success() { debug!("opencode exited with non-zero status: {:?}", status.code()); } let output = output_lines.join("\n"); // Try to parse JSON from output let mut review_json = parse_review_json(&output); let future_tasks = review_json .as_ref() .map(|json| normalize_future_tasks(&json.future_tasks)) .unwrap_or_default(); let summary = review_json.as_ref().and_then(|r| r.summary.clone()); let quality = review_json.as_mut().and_then(|r| r.quality.take()); let (issues_found, issues) = if let Some(ref json) = review_json { (json.issues_found, json.issues.clone()) } else { // Fallback: check for issue keywords let lowered = output.to_lowercase(); let has_issues = lowered.contains("bug") || lowered.contains("issue") || lowered.contains("error") || lowered.contains("problem") || lowered.contains("security") || lowered.contains("vulnerability") || lowered.contains("performance issue") || lowered.contains("memory leak"); (has_issues, Vec::new()) }; // Clean up temp file let _ = std::fs::remove_file(&diff_file); Ok(ReviewResult { issues_found, issues, summary, future_tasks, timed_out: false, quality, }) } /// Run Kimi CLI to review staged changes for bugs and performance issues. fn changed_files_from_diff(diff: &str) -> Vec<String> { let mut files = Vec::new(); for line in diff.lines() { if let Some(path) = line.strip_prefix("+++ b/") { if path != "/dev/null" { files.push(path.to_string()); } } } files.sort(); files.dedup(); files } fn issue_mentions_changed_file(issue: &str, files: &[String]) -> bool { for file in files { if issue.contains(file) { return true; } let with_b = format!("b/{}", file); if issue.contains(&with_b) { return true; } let with_dot = format!("./{}", file); if issue.contains(&with_dot) { return true; } } false } fn run_kimi_review( diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, _workdir: &std::path::Path, model: Option<&str>, ) -> Result<ReviewResult> { use std::io::{BufRead, Read, Write}; use std::sync::mpsc; use std::thread; let (diff_for_prompt, truncated) = truncate_diff(diff); let mut prompt = String::from( "Review this git diff for bugs, security issues, and performance problems. \ Only report issues that are directly supported by this diff. \ Each issue MUST include a file path and line number from the diff, in the format: \ \"path/to/file:line - description (evidence: `exact diff line`)\". \ Output ONLY a JSON object with this exact format: \ {\"issues_found\": true/false, \"issues\": [\"issue 1\", \"issue 2\"], \"summary\": \"brief summary\", \"future_tasks\": [\"optional follow-up\"]}. \ future_tasks are optional improvements/optimizations (max 3), actionable, and not duplicates of issues; use [] if none. \ If you cannot find concrete issues in the diff, set issues_found=false and issues=[].\n\n\ Git diff:\n", ); prompt.push_str(&diff_for_prompt); if truncated { prompt.push_str("\n\n[Diff truncated]"); } if let Some(instructions) = review_instructions { prompt.push_str(&format!( "\n\nAdditional review instructions:\n{}", instructions )); } if let Some(context) = session_context { prompt.push_str(&format!("\n\nContext:\n{}", context)); } info!( model = model.unwrap_or("default"), prompt_len = prompt.len(), "calling kimi for code review" ); let mut cmd = Command::new("kimi"); cmd.args([ "--print", "--input-format", "text", "--output-format", "stream-json", ]); if let Some(model) = model { if !model.trim().is_empty() { cmd.args(["--model", model]); } } cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd.spawn().context("failed to run kimi for review")?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(prompt.as_bytes()) .context("failed to write prompt to kimi")?; } let stdout = child .stdout .take() .context("failed to capture kimi stdout")?; let stderr = child .stderr .take() .context("failed to capture kimi stderr")?; let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(); let (stderr_tx, stderr_rx) = mpsc::channel::<String>(); let stdout_handle = thread::spawn(move || { let mut buf = Vec::new(); let _ = std::io::BufReader::new(stdout).read_to_end(&mut buf); let _ = stdout_tx.send(buf); }); let stderr_handle = thread::spawn(move || { let mut collected = String::new(); let reader = std::io::BufReader::new(stderr); for line in reader.lines().flatten() { // Stream stderr (progress/errors) to console if !line.trim().is_empty() { eprintln!("{}", line); } collected.push_str(&line); collected.push('\n'); } let _ = stderr_tx.send(collected); }); let status = child.wait().context("failed to wait for kimi")?; let _ = stdout_handle.join(); let _ = stderr_handle.join(); let stdout_bytes = stdout_rx.recv().unwrap_or_default(); let stderr_text = stderr_rx.recv().unwrap_or_default(); if !status.success() { let stdout_text = String::from_utf8_lossy(&stdout_bytes); let error_msg = if stderr_text.trim().is_empty() { stdout_text.trim() } else { stderr_text.trim() }; bail!("kimi review failed: {}", error_msg); } let stdout_text = String::from_utf8_lossy(&stdout_bytes).trim().to_string(); if stdout_text.is_empty() { bail!("kimi returned empty output"); } // Parse the stream-json output from kimi // Format: {"role":"assistant","content":[{"type":"think","think":"..."},{"type":"text","text":"..."}]} let result = extract_kimi_text_content(&stdout_text).unwrap_or_else(|| stdout_text.clone()); if result.is_empty() { bail!("kimi returned empty review output (no text content in response)"); } // Try to parse JSON from output let mut review_json = parse_review_json(&result); let future_tasks = review_json .as_ref() .map(|json| normalize_future_tasks(&json.future_tasks)) .unwrap_or_default(); let mut summary = review_json.as_ref().and_then(|r| r.summary.clone()); let quality = review_json.as_mut().and_then(|r| r.quality.take()); let (mut issues_found, mut issues) = if let Some(ref json) = review_json { (json.issues_found, json.issues.clone()) } else { let lowered = result.to_lowercase(); let has_issues = lowered.contains("bug") || lowered.contains("issue") || lowered.contains("error") || lowered.contains("problem") || lowered.contains("security") || lowered.contains("vulnerability") || lowered.contains("performance issue") || lowered.contains("memory leak"); (has_issues, Vec::new()) }; let changed_files = changed_files_from_diff(diff); if !issues.is_empty() && !changed_files.is_empty() { let before = issues.len(); issues.retain(|issue| issue_mentions_changed_file(issue, &changed_files)); let dropped = before.saturating_sub(issues.len()); if dropped > 0 { let note = format!( "Filtered {} unverified issue(s) that did not reference files in the diff.", dropped ); let summary = match summary.take() { Some(existing) if !existing.is_empty() => format!("{} {}", existing, note), _ => note, }; issues_found = !issues.is_empty(); return Ok(ReviewResult { issues_found, issues, summary: Some(summary), future_tasks, timed_out: false, quality: quality.clone(), }); } } if issues.is_empty() { issues_found = false; } Ok(ReviewResult { issues_found, issues, summary, future_tasks, timed_out: false, quality, }) } fn run_openrouter_review( diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, _workdir: &std::path::Path, model: &str, ) -> Result<ReviewResult> { let (diff_for_prompt, truncated) = truncate_diff(diff); let mut prompt = String::from( "Review this git diff for bugs, security issues, and performance problems. \ Only report issues that are directly supported by this diff. \ Each issue MUST include a file path and line number from the diff, in the format: \ \"path/to/file:line - description (evidence: `exact diff line`)\". \ Output ONLY a JSON object with this exact format: \ {\"issues_found\": true/false, \"issues\": [\"issue 1\", \"issue 2\"], \"summary\": \"brief summary\", \"future_tasks\": [\"optional follow-up\"]}. \ future_tasks are optional improvements/optimizations (max 3), actionable, and not duplicates of issues; use [] if none. \ If you cannot find concrete issues in the diff, set issues_found=false and issues=[].\n\n\ Git diff:\n", ); prompt.push_str(&diff_for_prompt); if truncated { prompt.push_str("\n\n[Diff truncated]"); } if let Some(instructions) = review_instructions { prompt.push_str(&format!( "\n\nAdditional review instructions:\n{}", instructions )); } if let Some(context) = session_context { prompt.push_str(&format!("\n\nContext:\n{}", context)); } let api_key = openrouter_api_key()?; let model_id = openrouter_model_id(model); let client = openrouter_http_client(Duration::from_secs(120))?; let body = ChatRequest { model: model_id.to_string(), messages: vec![ Message { role: "system".to_string(), content: "You are a code reviewer. Analyze code changes for bugs, security issues, and performance problems. Output JSON only.".to_string(), }, Message { role: "user".to_string(), content: prompt, }, ], temperature: 0.3, }; info!( model = model_id, prompt_len = body.messages[1].content.len(), "calling OpenRouter for code review" ); let start = std::time::Instant::now(); let parsed: ChatResponse = openrouter_chat_completion_with_retry(&client, &api_key, &body) .context("OpenRouter request failed")?; info!( elapsed_ms = start.elapsed().as_millis() as u64, "OpenRouter responded" ); let output = parsed .choices .first() .and_then(|c| c.message.as_ref()) .map(|m| m.content.trim().to_string()) .unwrap_or_default(); if output.is_empty() { bail!("OpenRouter returned empty review output"); } println!("{}", output); let mut review_json = parse_review_json(&output); let future_tasks = review_json .as_ref() .map(|json| normalize_future_tasks(&json.future_tasks)) .unwrap_or_default(); let mut summary = review_json.as_ref().and_then(|r| r.summary.clone()); let quality = review_json.as_mut().and_then(|r| r.quality.take()); let (mut issues_found, mut issues) = if let Some(ref json) = review_json { (json.issues_found, json.issues.clone()) } else { let lowered = output.to_lowercase(); let has_issues = lowered.contains("bug") || lowered.contains("issue") || lowered.contains("error") || lowered.contains("problem") || lowered.contains("security") || lowered.contains("vulnerability") || lowered.contains("performance issue") || lowered.contains("memory leak"); (has_issues, Vec::new()) }; let changed_files = changed_files_from_diff(diff); if !issues.is_empty() && !changed_files.is_empty() { let before = issues.len(); issues.retain(|issue| issue_mentions_changed_file(issue, &changed_files)); let dropped = before.saturating_sub(issues.len()); if dropped > 0 { let note = format!( "Filtered {} unverified issue(s) that did not reference files in the diff.", dropped ); let summary = match summary.take() { Some(existing) if !existing.is_empty() => format!("{} {}", existing, note), _ => note, }; issues_found = !issues.is_empty(); return Ok(ReviewResult { issues_found, issues, summary: Some(summary), future_tasks, timed_out: false, quality: quality.clone(), }); } } if issues.is_empty() { issues_found = false; } Ok(ReviewResult { issues_found, issues, summary, future_tasks, timed_out: false, quality, }) } const OPENROUTER_CHAT_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; fn openrouter_http_client(timeout: Duration) -> Result<Client> { Client::builder() .timeout(timeout) // OpenRouter occasionally drops pooled connections mid-body, producing // `unexpected EOF during chunk size line`. Disabling idle pooling makes // these transient failures much rarer for CLI-style, low-QPS usage. .pool_max_idle_per_host(0) .build() .context("failed to create HTTP client") } fn openrouter_should_retry_error(err: &reqwest::Error) -> bool { if err.is_timeout() || err.is_connect() || err.is_body() { return true; } // reqwest/hyper doesn't expose a stable typed error for this; match common symptoms. let msg = err.to_string().to_lowercase(); msg.contains("unexpected eof") || msg.contains("chunk size line") || msg.contains("connection closed") || msg.contains("incomplete message") } fn openrouter_retry_after(resp: &reqwest::blocking::Response) -> Option<Duration> { let value = resp.headers().get("retry-after")?.to_str().ok()?; // Spec also allows HTTP-date; we only handle integer seconds. let secs: u64 = value.trim().parse().ok()?; Some(Duration::from_secs(secs)) } fn openrouter_chat_completion_with_retry( client: &Client, api_key: &str, body: &ChatRequest, ) -> Result<ChatResponse> { let max_attempts = 3usize; let mut backoff = Duration::from_millis(250); let mut last_err: Option<anyhow::Error> = None; for attempt in 1..=max_attempts { let resp = client .post(OPENROUTER_CHAT_URL) .header("Authorization", format!("Bearer {}", api_key)) .header("HTTP-Referer", "https://github.com/nikitavoloboev/flow") .header("Accept", "application/json") .json(body) .send(); let resp = match resp { Ok(resp) => resp, Err(err) => { let retry = openrouter_should_retry_error(&err) && attempt < max_attempts; let err = anyhow::Error::new(err).context("failed to call OpenRouter API"); if retry { info!( attempt = attempt, max_attempts = max_attempts, backoff_ms = backoff.as_millis() as u64, "OpenRouter request error (transient), retrying" ); last_err = Some(err); std::thread::sleep(backoff); backoff = backoff.saturating_mul(2); continue; } return Err(err); } }; let status = resp.status(); let retry_after = openrouter_retry_after(&resp); let request_id = resp .headers() .get("x-request-id") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) .or_else(|| { resp.headers() .get("cf-ray") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) }); let body_bytes = match resp.bytes() { Ok(bytes) => bytes, Err(err) => { let retry = openrouter_should_retry_error(&err) && attempt < max_attempts; let mut err = anyhow::Error::new(err).context("failed to read OpenRouter response body"); if let Some(rid) = request_id.as_deref() { err = err.context(format!("OpenRouter request id: {}", rid)); } if retry { info!( attempt = attempt, max_attempts = max_attempts, backoff_ms = backoff.as_millis() as u64, "OpenRouter body read error (transient), retrying" ); last_err = Some(err); std::thread::sleep(backoff); backoff = backoff.saturating_mul(2); continue; } return Err(err); } }; if !status.is_success() { let is_retryable_status = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error(); let text = String::from_utf8_lossy(&body_bytes).trim().to_string(); if is_retryable_status && attempt < max_attempts { let sleep_for = retry_after.unwrap_or(backoff); info!( attempt = attempt, max_attempts = max_attempts, status = %status, sleep_ms = sleep_for.as_millis() as u64, "OpenRouter returned transient status, retrying" ); last_err = Some(anyhow::anyhow!("OpenRouter API error {}: {}", status, text)); std::thread::sleep(sleep_for); backoff = backoff.saturating_mul(2); continue; } let mut err = anyhow::anyhow!("OpenRouter API error {}: {}", status, text); if let Some(rid) = request_id.as_deref() { err = err.context(format!("OpenRouter request id: {}", rid)); } return Err(err); } match serde_json::from_slice::<ChatResponse>(&body_bytes) { Ok(parsed) => return Ok(parsed), Err(err) => { let snippet = { let s = String::from_utf8_lossy(&body_bytes); let s = s.trim(); let max = 600usize; if s.len() > max { format!("{}...", &s[..max]) } else { s.to_string() } }; let mut err = anyhow::Error::new(err) .context("failed to decode OpenRouter JSON response") .context(format!("response snippet: {}", snippet)); if let Some(rid) = request_id.as_deref() { err = err.context(format!("OpenRouter request id: {}", rid)); } return Err(err); } } } Err(last_err.unwrap_or_else(|| anyhow::anyhow!("OpenRouter request failed after retries"))) } /// Run Rise daemon to review staged changes for bugs and performance issues. fn run_rise_review( diff: &str, session_context: Option<&str>, review_instructions: Option<&str>, _workdir: &std::path::Path, model: &str, ) -> Result<ReviewResult> { let (diff_for_prompt, _truncated) = truncate_diff(diff); // Build review prompt let mut prompt = String::from( "Review this git diff for bugs, security issues, and performance problems. \ Output ONLY a JSON object with this exact format: \ {\"issues_found\": true/false, \"issues\": [\"issue 1\", \"issue 2\"], \"summary\": \"brief summary\", \"future_tasks\": [\"optional follow-up\"]}. \ future_tasks are optional improvements/optimizations (max 3), actionable, and not duplicates of issues; use [] if none.\n\n\ Git diff:\n", ); prompt.push_str(&diff_for_prompt); if let Some(instructions) = review_instructions { prompt.push_str(&format!( "\n\nAdditional review instructions:\n{}", instructions )); } if let Some(context) = session_context { prompt.push_str(&format!("\n\nContext:\n{}", context)); } let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(120)) .context("failed to create HTTP client")?; let body = ChatRequest { model: model.to_string(), messages: vec![ Message { role: "system".to_string(), content: "You are a code reviewer. Analyze code changes for bugs, security issues, and performance problems. Output JSON only.".to_string(), }, Message { role: "user".to_string(), content: prompt, }, ], temperature: 0.3, }; info!(model = model, "calling Rise daemon for code review"); let start = std::time::Instant::now(); let rise_url = rise_url(); let text = send_rise_request_text(&client, &rise_url, &body, model)?; info!( elapsed_ms = start.elapsed().as_millis() as u64, "Rise daemon responded" ); let output = parse_rise_output(&text).context("failed to parse Rise response")?; println!("{}", output); // Try to parse JSON from output let mut review_json = parse_review_json(&output); let future_tasks = review_json .as_ref() .map(|json| normalize_future_tasks(&json.future_tasks)) .unwrap_or_default(); let summary = review_json.as_ref().and_then(|r| r.summary.clone()); let quality = review_json.as_mut().and_then(|r| r.quality.take()); let (issues_found, issues) = if let Some(ref json) = review_json { (json.issues_found, json.issues.clone()) } else { // Fallback: check for issue keywords let lowered = output.to_lowercase(); let has_issues = lowered.contains("bug") || lowered.contains("issue") || lowered.contains("error") || lowered.contains("problem") || lowered.contains("security") || lowered.contains("vulnerability") || lowered.contains("performance issue") || lowered.contains("memory leak"); (has_issues, Vec::new()) }; Ok(ReviewResult { issues_found, issues, summary, future_tasks, timed_out: false, quality, }) } fn ensure_git_repo() -> Result<()> { let _ = vcs::ensure_jj_repo()?; let output = Command::new("git") .args(["rev-parse", "--git-dir"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .context("failed to run git")?; if !output.success() { bail!("Not a git repository"); } Ok(()) } fn git_root_or_cwd() -> std::path::PathBuf { match git_capture(&["rev-parse", "--show-toplevel"]) { Ok(root) => std::path::PathBuf::from(root.trim()), Err(_) => std::env::current_dir().unwrap_or_default(), } } fn warn_if_commit_invoked_from_subdir(repo_root: &Path) { let Ok(cwd) = std::env::current_dir() else { return; }; let cwd_norm = cwd.canonicalize().unwrap_or(cwd.clone()); let root_norm = repo_root .canonicalize() .unwrap_or_else(|_| repo_root.to_path_buf()); if cwd_norm == root_norm { return; } println!( "warning: commit invoked from subdirectory: {}", cwd.display() ); println!( "warning: using git repo root for commit operations: {}", repo_root.display() ); } fn ensure_commit_setup(repo_root: &Path) -> Result<()> { let ai_internal = repo_root.join(".ai").join("internal"); fs::create_dir_all(&ai_internal) .with_context(|| format!("failed to create {}", ai_internal.display()))?; setup::add_gitignore_entry(repo_root, ".ai/internal/")?; Ok(()) } fn ensure_no_internal_staged(repo_root: &Path) -> Result<()> { if env::var("FLOW_ALLOW_INTERNAL_COMMIT").as_deref() == Ok("1") { return Ok(()); } let staged = internal_staged_paths(repo_root); if staged.is_empty() { return Ok(()); } println!("Refusing to commit internal .ai files:"); for path in staged { println!(" - {}", path); } println!("Remove these from staging or set FLOW_ALLOW_INTERNAL_COMMIT=1 to override."); bail!("Refusing to commit internal .ai files"); } fn internal_staged_paths(repo_root: &Path) -> Vec<String> { let output = Command::new("git") .args(["diff", "--cached", "--name-only"]) .current_dir(repo_root) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } let files = String::from_utf8_lossy(&output.stdout); files .lines() .filter(|path| { path.starts_with(".ai/internal/") || path == &".ai/internal" || (path.starts_with(".ai/todos/") && path.ends_with(".bike")) }) .map(|path| path.to_string()) .collect() } fn ensure_no_unwanted_staged(repo_root: &Path) -> Result<()> { if env::var("FLOW_ALLOW_UNWANTED_COMMIT") .ok() .map(|v| { let v = v.to_ascii_lowercase(); v == "1" || v == "true" || v == "yes" }) .unwrap_or(false) { return Ok(()); } let staged = unwanted_staged_paths(repo_root); if staged.is_empty() { return Ok(()); } let mut ignore_entries = HashSet::new(); let mut saw_personal_tooling = false; for (path, reason) in &staged { println!("Refusing to commit generated file: {} ({})", path, reason); // Personal tooling entries belong in global gitignore, not project .gitignore. if path.starts_with(".beads/") || path == ".beads" { saw_personal_tooling = true; continue; } if path == ".rise" || path.starts_with(".rise/") || path.contains("/.rise/") { saw_personal_tooling = true; continue; } if path.ends_with(".pyc") || path.contains("/__pycache__/") || path.ends_with("/__pycache__") { ignore_entries.insert("__pycache__/"); ignore_entries.insert("*.pyc"); } } for entry in &ignore_entries { let _ = setup::add_gitignore_entry(repo_root, entry); } for (path, _) in &staged { let _ = git_run_in(repo_root, &["reset", "HEAD", "--", path]); } if !ignore_entries.is_empty() { println!("Added ignore rules for generated files and unstaged them."); } else { println!("Unstaged generated files."); } if saw_personal_tooling { println!( "Personal tooling paths (.beads/, .rise/) should be ignored globally, not in project .gitignore." ); println!("Run `f gitignore policy-init` and `f gitignore fix` to clean existing repos."); } println!("Re-run `f commit` after verifying the changes."); println!("Set FLOW_ALLOW_UNWANTED_COMMIT=1 to override."); bail!("Refusing to commit generated files"); } fn unwanted_staged_paths(repo_root: &Path) -> Vec<(String, String)> { let output = Command::new("git") .args(["diff", "--cached", "--name-status", "-z"]) .current_dir(repo_root) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } let mut out = Vec::new(); let raw = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = raw.split('\0').collect(); let mut i = 0; while i < parts.len() { let status = parts[i]; i += 1; if status.is_empty() { continue; } let path = if status.starts_with('R') || status.starts_with('C') { if i + 1 >= parts.len() { break; } let new_path = parts[i + 1]; i += 2; new_path } else { if i >= parts.len() { break; } let path = parts[i]; i += 1; path }; if status.starts_with('D') { continue; } if let Some(reason) = unwanted_reason(path) { out.push((path.to_string(), reason.to_string())); } } out } fn unwanted_reason(path: &str) -> Option<&'static str> { if path == ".flow/deploy-log.json" || path.ends_with("/.flow/deploy-log.json") { return Some("flow deploy state"); } if path == ".beads" || path.starts_with(".beads/") || path.contains("/.beads/") { return Some("beads metadata"); } if path == ".rise" || path.starts_with(".rise/") || path.contains("/.rise/") { return Some("rise metadata"); } if path.ends_with(".pyc") { return Some("python bytecode"); } if path.ends_with("/__pycache__") || path.contains("/__pycache__/") || path.starts_with("__pycache__/") { return Some("python cache"); } None } fn log_commit_event_for_repo( repo_root: &Path, message: &str, command: &str, review: Option<ai::CommitReviewSummary>, context_chars: Option<usize>, ) { let commit_sha = match git_capture_in(repo_root, &["rev-parse", "HEAD"]) { Ok(sha) => sha, Err(err) => { debug!("failed to capture commit SHA for commit log: {}", err); return; } }; let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()); let author_name = git_capture_in(repo_root, &["log", "-1", "--format=%an"]) .unwrap_or_else(|_| "unknown".to_string()); let author_email = git_capture_in(repo_root, &["log", "-1", "--format=%ae"]) .unwrap_or_else(|_| "unknown".to_string()); ai::log_commit_event( &repo_root.to_path_buf(), commit_sha.trim(), branch.trim(), message, author_name.trim(), author_email.trim(), command, review, context_chars, ); } /// Record an undoable commit action. /// Call this after a successful commit (and optionally push). fn record_undo_action(repo_root: &Path, pushed: bool, message: Option<&str>) { // Get the current HEAD (after commit) let after_sha = match git_capture_in(repo_root, &["rev-parse", "HEAD"]) { Ok(sha) => sha.trim().to_string(), Err(_) => return, }; // Get the parent commit (before commit) let before_sha = match git_capture_in(repo_root, &["rev-parse", "HEAD~1"]) { Ok(sha) => sha.trim().to_string(), Err(_) => return, }; // Get current branch let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()); let action_type = if pushed { undo::ActionType::CommitPush } else { undo::ActionType::Commit }; let push_remote = config::preferred_git_remote_for_repo(repo_root); let remote_for_undo = if pushed { Some(push_remote.as_str()) } else { None }; if let Err(e) = undo::record_action( repo_root, action_type, &before_sha, &after_sha, branch.trim(), pushed, remote_for_undo, message, ) { debug!("failed to record undo action: {}", e); } } const COMMIT_QUEUE_DIR: &str = ".ai/internal/commit-queue"; #[derive(Debug, Clone, Serialize, Deserialize)] struct CommitQueueEntry { version: u8, created_at: String, repo_root: String, branch: String, commit_sha: String, message: String, review_bookmark: Option<String>, #[serde(default)] review_completed: bool, #[serde(default)] review_issues_found: bool, #[serde(default)] review_timed_out: bool, #[serde(skip_serializing_if = "Option::is_none")] review_model: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] review_reviewer: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] review_todo_ids: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] pr_url: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pr_number: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] pr_head: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pr_base: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] analysis: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] review: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] summary: Option<String>, #[serde(skip)] record_path: Option<PathBuf>, } const RISE_REVIEW_DIR: &str = ".ai/internal/rise-review"; const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; #[derive(Debug, Serialize)] struct RiseReviewFileEntry { status: String, path: String, #[serde(skip_serializing_if = "Option::is_none", rename = "originalPath")] original_path: Option<String>, } #[derive(Debug, Serialize)] struct RiseReviewSession { version: u8, #[serde(rename = "created_at")] created_at: String, #[serde(rename = "repoRoot")] repo_root: String, commit: String, #[serde(skip_serializing_if = "Option::is_none")] parent: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] bookmark: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] branch: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] message: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] analysis: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] review: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] summary: Option<String>, files: Vec<RiseReviewFileEntry>, } fn short_sha(sha: &str) -> &str { if sha.len() <= 7 { sha } else { &sha[..7] } } fn commit_queue_dir(repo_root: &Path) -> PathBuf { repo_root.join(COMMIT_QUEUE_DIR) } fn commit_queue_entry_path(repo_root: &Path, sha: &str) -> PathBuf { commit_queue_dir(repo_root).join(format!("{}.json", sha)) } fn write_commit_queue_entry(repo_root: &Path, entry: &CommitQueueEntry) -> Result<PathBuf> { let dir = commit_queue_dir(repo_root); fs::create_dir_all(&dir)?; let payload = serde_json::to_string_pretty(entry).context("serialize commit queue entry")?; let path = commit_queue_entry_path(repo_root, &entry.commit_sha); fs::write(&path, payload).context("write commit queue entry")?; Ok(path) } fn format_review_body(review: &ReviewResult) -> Option<String> { if review.issues.is_empty() { return None; } let mut out = String::new(); for issue in &review.issues { if !out.is_empty() { out.push('\n'); } out.push_str("- "); out.push_str(issue); } Some(out) } fn resolve_commit_parent(repo_root: &Path, commit_sha: &str) -> String { match git_capture_in(repo_root, &["rev-parse", &format!("{}^", commit_sha)]) { Ok(parent) => { let trimmed = parent.trim().to_string(); if trimmed.is_empty() { EMPTY_TREE_HASH.to_string() } else { trimmed } } Err(_) => EMPTY_TREE_HASH.to_string(), } } fn resolve_commit_message(repo_root: &Path, entry: &CommitQueueEntry) -> Option<String> { if !entry.message.trim().is_empty() { return Some(entry.message.clone()); } git_capture_in(repo_root, &["log", "-1", "--format=%B", &entry.commit_sha]) .ok() .map(|message| message.trim().to_string()) .filter(|message| !message.is_empty()) } fn resolve_review_files(repo_root: &Path, commit_sha: &str) -> Vec<RiseReviewFileEntry> { let output = git_capture_in( repo_root, &[ "diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "-M", commit_sha, ], ); let Ok(output) = output else { return Vec::new(); }; output .lines() .filter_map(|line| { if line.trim().is_empty() { return None; } let mut parts = line.split('\t'); let status = parts.next().unwrap_or_default().trim().to_string(); if status.starts_with('R') || status.starts_with('C') { let original = parts.next().unwrap_or_default().trim().to_string(); let path = parts.next().unwrap_or_default().trim().to_string(); if path.is_empty() { return None; } return Some(RiseReviewFileEntry { status, path, original_path: if original.is_empty() { None } else { Some(original) }, }); } let path = parts.next().unwrap_or_default().trim().to_string(); if path.is_empty() { return None; } Some(RiseReviewFileEntry { status, path, original_path: None, }) }) .collect() } fn write_rise_review_session(repo_root: &Path, entry: &CommitQueueEntry) -> Result<PathBuf> { let review_dir = repo_root.join(RISE_REVIEW_DIR); fs::create_dir_all(&review_dir) .with_context(|| format!("failed to create {}", review_dir.display()))?; let session = RiseReviewSession { version: 1, created_at: entry.created_at.clone(), repo_root: entry.repo_root.clone(), commit: entry.commit_sha.clone(), parent: Some(resolve_commit_parent(repo_root, &entry.commit_sha)), bookmark: entry.review_bookmark.clone(), branch: Some(entry.branch.clone()), message: resolve_commit_message(repo_root, entry), analysis: entry.analysis.clone(), review: entry.review.clone(), summary: entry.summary.clone(), files: resolve_review_files(repo_root, &entry.commit_sha), }; let path = review_dir.join(format!("review-{}.json", entry.commit_sha)); let payload = serde_json::to_string_pretty(&session).context("serialize rise review session")?; fs::write(&path, payload).context("write rise review session")?; Ok(path) } fn rise_review_path(repo_root: &Path, commit_sha: &str) -> PathBuf { repo_root .join(RISE_REVIEW_DIR) .join(format!("review-{}.json", commit_sha)) } fn delete_rise_review_session(repo_root: &Path, commit_sha: &str) { let path = rise_review_path(repo_root, commit_sha); if path.exists() { let _ = fs::remove_file(path); } } fn git_is_ancestor(repo_root: &Path, ancestor: &str, descendant: &str) -> bool { Command::new("git") .current_dir(repo_root) .args(["merge-base", "--is-ancestor", ancestor, descendant]) .status() .map(|status| status.success()) .unwrap_or(false) } fn load_commit_queue_entries(repo_root: &Path) -> Result<Vec<CommitQueueEntry>> { let dir = commit_queue_dir(repo_root); if !dir.exists() { return Ok(Vec::new()); } let mut entries = Vec::new(); for entry in fs::read_dir(&dir).context("read commit queue directory")? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } let content = fs::read_to_string(&path).unwrap_or_default(); match serde_json::from_str::<CommitQueueEntry>(&content) { Ok(mut parsed) => { parsed.record_path = Some(path); entries.push(parsed); } Err(err) => debug!(path = %path.display(), error = %err, "invalid commit queue entry"), } } entries.sort_by(|a, b| a.created_at.cmp(&b.created_at)); Ok(entries) } fn resolve_commit_queue_entry(repo_root: &Path, hash: &str) -> Result<CommitQueueEntry> { let entries = load_commit_queue_entries(repo_root)?; let matches: Vec<_> = entries .into_iter() .filter(|entry| commit_queue_entry_matches(entry, hash)) .collect(); match matches.len() { 0 => bail!("No queued commit matches {}", hash), 1 => Ok(matches.into_iter().next().unwrap()), _ => bail!("Multiple queued commits match {}. Use a longer hash.", hash), } } fn resolve_git_commit_sha(repo_root: &Path, hash: &str) -> Result<String> { let rev = format!("{hash}^{{commit}}"); let sha = git_capture_in(repo_root, &["rev-parse", "--verify", &rev]) .with_context(|| format!("{hash} is not a valid git commit"))?; let trimmed = sha.trim(); if trimmed.is_empty() { bail!("{hash} is not a valid git commit"); } Ok(trimmed.to_string()) } fn queue_existing_commit_for_approval( repo_root: &Path, hash: &str, mark_reviewed: bool, ) -> Result<CommitQueueEntry> { let commit_sha = resolve_git_commit_sha(repo_root, hash)?; let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let message = git_capture_in(repo_root, &["log", "-1", "--format=%s", &commit_sha]) .unwrap_or_default() .trim() .to_string(); let review_bookmark = create_review_bookmark(repo_root, &commit_sha, &branch).ok(); let mut entry = CommitQueueEntry { version: 2, created_at: chrono::Utc::now().to_rfc3339(), repo_root: repo_root.display().to_string(), branch, commit_sha: commit_sha.clone(), message, review_bookmark, review_completed: mark_reviewed, review_issues_found: false, review_timed_out: !mark_reviewed, review_model: if mark_reviewed { Some("manual-codex".to_string()) } else { None }, review_reviewer: if mark_reviewed { Some("codex".to_string()) } else { None }, review_todo_ids: Vec::new(), pr_url: None, pr_number: None, pr_head: None, pr_base: None, analysis: None, review: None, summary: if mark_reviewed { Some("Manually reviewed with Codex; approved for push.".to_string()) } else { Some("Queued from git history without review metadata.".to_string()) }, record_path: None, }; let path = write_commit_queue_entry(repo_root, &entry)?; entry.record_path = Some(path); if let Err(err) = write_rise_review_session(repo_root, &entry) { debug!("failed to write rise review session: {}", err); } Ok(entry) } fn remove_commit_queue_entry_by_entry(repo_root: &Path, entry: &CommitQueueEntry) -> Result<()> { if let Some(path) = entry.record_path.as_ref() { if path.exists() { fs::remove_file(path).context("remove commit queue entry")?; } } let path = commit_queue_entry_path(repo_root, &entry.commit_sha); if path.exists() { fs::remove_file(&path).context("remove commit queue entry")?; } delete_rise_review_session(repo_root, &entry.commit_sha); Ok(()) } fn queue_commit_for_review( repo_root: &Path, message: &str, review: Option<&ReviewResult>, review_model: Option<&str>, review_reviewer: Option<&str>, review_todo_ids: Vec<String>, ) -> Result<String> { let commit_sha = git_capture_in(repo_root, &["rev-parse", "HEAD"])? .trim() .to_string(); let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let review_bookmark = create_review_bookmark(repo_root, &commit_sha, &branch).ok(); let summary = review .and_then(|value| value.summary.clone()) .and_then(|value| { let trimmed = value.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } }); let review_body = review.and_then(format_review_body); let entry = CommitQueueEntry { version: 2, created_at: chrono::Utc::now().to_rfc3339(), repo_root: repo_root.display().to_string(), branch, commit_sha: commit_sha.clone(), message: message.to_string(), review_bookmark, review_completed: review.is_some(), review_issues_found: review.map(|r| r.issues_found).unwrap_or(false), review_timed_out: review.map(|r| r.timed_out).unwrap_or(false), review_model: review_model.map(|s| s.to_string()), review_reviewer: review_reviewer.map(|s| s.to_string()), review_todo_ids, pr_url: None, pr_number: None, pr_head: None, pr_base: None, analysis: None, review: review_body, summary, record_path: None, }; let path = write_commit_queue_entry(repo_root, &entry)?; let _ = path; if let Err(err) = write_rise_review_session(repo_root, &entry) { debug!("failed to write rise review session: {}", err); } Ok(commit_sha) } fn open_review_in_rise(repo_root: &Path, commit_sha: &str) { // Prefer rise-app (VS Code fork) because it has the best multi-file diff UX. // Fall back to `rise review open` if rise-app isn't installed. let (cmd, args): (String, Vec<String>) = if let Ok(rise_app_path) = which::which("rise-app") { // Ensure review file exists, then open it explicitly. let review_file = rise_review_path(repo_root, commit_sha); if !review_file.exists() { // Best-effort recreate; failures here shouldn't block. if let Ok(entry) = resolve_commit_queue_entry(repo_root, commit_sha) { let _ = write_rise_review_session(repo_root, &entry); } } // Some installations place the JS wrapper directly on PATH without a shebang. // In that case, execute it with node. let launch_with_node = fs::read(&rise_app_path) .ok() .and_then(|bytes| { bytes .get(0..128) .map(|chunk| String::from_utf8_lossy(chunk).to_string()) }) .map(|head| { !head.starts_with("#!") && (head.starts_with("/*") || head.starts_with("//")) }) .unwrap_or(false); if launch_with_node { ( "node".to_string(), vec![ rise_app_path.display().to_string(), "review".to_string(), "--review-file".to_string(), review_file.display().to_string(), ], ) } else { ( rise_app_path.display().to_string(), vec![ "review".to_string(), "--review-file".to_string(), review_file.display().to_string(), ], ) } } else if which::which("rise").is_ok() { ( "rise".to_string(), vec![ "review".to_string(), "open".to_string(), "--queue".to_string(), commit_sha.to_string(), ], ) } else { println!("Rise not found on PATH; skipping review open."); return; }; let status = Command::new(&cmd) .args(&args) .current_dir(repo_root) .status(); match status { Ok(status) => { if !status.success() { println!("⚠ Failed to open review (exit {}).", status); } } Err(err) => println!("⚠ Failed to run review opener: {}", err), } } pub fn open_latest_queue_review() -> Result<()> { ensure_git_repo()?; let repo_root = git_root_or_cwd(); let _ = refresh_commit_queue(&repo_root); let mut entries = load_commit_queue_entries(&repo_root)?; if entries.is_empty() { bail!("Commit queue is empty."); } let entry = entries.pop().unwrap(); println!( "Opening latest queued commit {} in Rise...", short_sha(&entry.commit_sha) ); open_review_in_rise(&repo_root, &entry.commit_sha); Ok(()) } fn latest_review_report_for_commit(repo_root: &Path, commit_sha: &str) -> Option<PathBuf> { let report_dir = flow_commit_reports_dir()?; let sha_short = short_sha(commit_sha); let project_slug = safe_label_value(&flow_project_name(repo_root)); let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let branch_slug = safe_label_value(&branch); let strict_prefix = format!("{project_slug}-{branch_slug}-{sha_short}-"); let mut strict_matches: Vec<PathBuf> = Vec::new(); let mut fallback_matches: Vec<PathBuf> = Vec::new(); for entry in fs::read_dir(&report_dir).ok()? { let path = entry.ok()?.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; if file_name.starts_with(&strict_prefix) { strict_matches.push(path); } else if file_name.contains(&format!("-{sha_short}-")) { fallback_matches.push(path); } } strict_matches.sort_by(|a, b| a.file_name().cmp(&b.file_name())); fallback_matches.sort_by(|a, b| a.file_name().cmp(&b.file_name())); strict_matches.pop().or_else(|| fallback_matches.pop()) } fn queued_review_counts_excluding( repo_root: &Path, excluded_commit_sha: &str, ) -> Result<(usize, usize, String)> { let entries = load_commit_queue_entries(repo_root)?; let current_branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let mut total_other = 0usize; let mut branch_other = 0usize; for entry in entries { if entry.commit_sha == excluded_commit_sha { continue; } total_other += 1; if entry.branch.trim() == current_branch { branch_other += 1; } } Ok((branch_other, total_other, current_branch)) } fn print_other_queued_review_count(repo_root: &Path, commit_sha: &str) { let Ok((branch_other, total_other, current_branch)) = queued_review_counts_excluding(repo_root, commit_sha) else { return; }; if total_other == 0 { println!("No other queued commits pending review."); return; } println!( "{} other queued commit(s) pending review ({} on current branch {}).", total_other, branch_other, current_branch ); } fn copy_text_to_clipboard(text: &str) -> Result<bool> { if std::env::var("FLOW_NO_CLIPBOARD").is_ok() || !std::io::stdin().is_terminal() { return Ok(false); } #[cfg(target_os = "macos")] { let mut child = Command::new("pbcopy") .stdin(Stdio::piped()) .spawn() .context("failed to spawn pbcopy")?; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(text.as_bytes())?; } child.wait()?; return Ok(true); } #[cfg(target_os = "linux")] { let result = Command::new("xclip") .arg("-selection") .arg("clipboard") .stdin(Stdio::piped()) .spawn(); let mut child = match result { Ok(c) => c, Err(_) => Command::new("xsel") .arg("--clipboard") .arg("--input") .stdin(Stdio::piped()) .spawn() .context("failed to spawn xclip or xsel")?, }; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(text.as_bytes())?; } child.wait()?; return Ok(true); } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { bail!("clipboard not supported on this platform"); } } fn build_review_prompt_payload( repo_root: &Path, entry: &CommitQueueEntry, report_path: Option<&Path>, ) -> String { let (branch_other, total_other, current_branch) = queued_review_counts_excluding( repo_root, &entry.commit_sha, ) .unwrap_or((0, 0, "unknown".to_string())); let mut out = String::new(); out.push_str("here is commit i want you to address fully\n\n"); out.push_str(&format!( "Repo: {}\nBranch: {}\nQueued commit: {}", repo_root.display(), entry.branch.trim(), short_sha(&entry.commit_sha) )); if let Some(bookmark) = entry .review_bookmark .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { out.push_str(&format!("\nReview bookmark: {}", bookmark)); } out.push_str("\n\nCommands:\n"); out.push_str(&format!( "f commit-queue show {}\n", short_sha(&entry.commit_sha) )); out.push_str(&format!( "f commit-queue approve {}\n", short_sha(&entry.commit_sha) )); out.push_str("f commit-queue approve --all\n"); if let Some(path) = report_path { out.push_str(&format!("\nReview report: {}\n", path.display())); out.push_str(&format!("Run: f fix {}\n", path.display())); } out.push_str("\nCommit message:\n"); out.push_str("────────────────────────────────────────\n"); out.push_str(entry.message.trim_end()); out.push_str("\n────────────────────────────────────────\n"); if let Some(summary) = entry .summary .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { out.push_str("\nReview summary:\n"); out.push_str(summary); out.push('\n'); } if let Some(review) = entry .review .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { out.push_str("\nReview findings:\n"); out.push_str(review); out.push('\n'); } if let Some(path) = report_path { if let Ok(markdown) = fs::read_to_string(path) { let trimmed = markdown.trim(); if !trimmed.is_empty() { out.push_str("\nReview report markdown:\n"); out.push_str(trimmed); out.push('\n'); } } } if total_other == 0 { out.push_str("\nOther queued commits pending review: 0\n"); } else { out.push_str(&format!( "\nOther queued commits pending review: {} ({} on current branch {})\n", total_other, branch_other, current_branch )); } out.push_str("\naddress this so we can push\n"); out } pub fn copy_review_prompt(hash: Option<&str>) -> Result<()> { ensure_git_repo()?; let repo_root = git_root_or_cwd(); let _ = refresh_commit_queue(&repo_root); let mut entry = if let Some(hash) = hash { resolve_commit_queue_entry(&repo_root, hash)? } else { let mut entries = load_commit_queue_entries(&repo_root)?; if entries.is_empty() { bail!("Commit queue is empty."); } entries.pop().unwrap() }; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); let report_path = latest_review_report_for_commit(&repo_root, &entry.commit_sha); let payload = build_review_prompt_payload(&repo_root, &entry, report_path.as_deref()); match copy_text_to_clipboard(&payload) { Ok(true) => println!( "Copied review prompt for {} to clipboard.", short_sha(&entry.commit_sha) ), Ok(false) => { println!("Clipboard copy skipped (non-interactive shell or FLOW_NO_CLIPBOARD).") } Err(err) => println!("⚠ Failed to copy review prompt to clipboard: {}", err), } println!("────────────────────────────────────────"); println!("{}", payload.trim_end()); println!("────────────────────────────────────────"); Ok(()) } fn print_queue_instructions(repo_root: &Path, commit_sha: &str) { println!("Queued commit {} for review.", short_sha(commit_sha)); println!(" f commit-queue list"); println!(" f commit-queue show {}", short_sha(commit_sha)); println!( " When review passes: f commit-queue approve {}", short_sha(commit_sha) ); println!(" When all pass: f commit-queue approve --all"); println!(" f review copy {}", short_sha(commit_sha)); print_other_queued_review_count(repo_root, commit_sha); } fn queue_review_status_label(entry: &CommitQueueEntry) -> &'static str { let issues_present = entry.review_issues_found || entry .review .as_deref() .map(|s| !s.trim().is_empty()) .unwrap_or(false); if entry.review_timed_out { "review timed out" } else if issues_present { "review issues" } else if entry.version >= 2 && !entry.review_completed { "review pending" } else { "review clean" } } fn print_pending_queue_review_hint(repo_root: &Path) { let mut entries = match load_commit_queue_entries(repo_root) { Ok(entries) => entries, Err(_) => return, }; if entries.is_empty() { return; } for entry in &mut entries { let _ = refresh_queue_entry_commit(repo_root, entry); } let current_branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let mut scoped_entries: Vec<&CommitQueueEntry> = entries .iter() .filter(|entry| entry.branch.trim() == current_branch) .collect(); let scoped_to_branch = !scoped_entries.is_empty(); if !scoped_to_branch { scoped_entries = entries.iter().collect(); } println!(); if scoped_to_branch { println!( "Queued commits pending review on branch {}:", current_branch ); } else { println!("Queued commits pending review (all branches):"); } let max_display = 5usize; for entry in scoped_entries.iter().take(max_display) { println!( " - {} {} {}", short_sha(&entry.commit_sha), format_queue_created_at(&entry.created_at), queue_review_status_label(entry) ); } if scoped_entries.len() > max_display { println!(" ... and {} more", scoped_entries.len() - max_display); } println!("Next:"); println!(" f commit-queue list"); println!(" f commit-queue approve --all"); } fn approve_all_queued_commits( repo_root: &Path, force: bool, allow_issues: bool, allow_unreviewed: bool, ) -> Result<()> { git_guard::ensure_clean_for_push(repo_root)?; let mut entries = load_commit_queue_entries(repo_root)?; if entries.is_empty() { println!("No queued commits."); return Ok(()); } for entry in &mut entries { let _ = refresh_queue_entry_commit(repo_root, entry); } let current_branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()); let current_branch = current_branch.trim().to_string(); let mut candidates = Vec::new(); let mut skipped_branch = Vec::new(); for entry in entries { if !force && entry.branch.trim() != current_branch { skipped_branch.push(entry); } else { candidates.push(entry); } } if candidates.is_empty() { if skipped_branch.is_empty() { println!("No queued commits to approve."); } else { println!( "No queued commits on branch {}. {} queued commit(s) are on other branches.", current_branch, skipped_branch.len() ); } return Ok(()); } if !force { let mut bad_issues: Vec<String> = Vec::new(); let mut bad_unreviewed: Vec<String> = Vec::new(); for entry in &candidates { let issues_present = entry.review_issues_found || entry .review .as_deref() .map(|s| !s.trim().is_empty()) .unwrap_or(false); let unreviewed = (entry.version >= 2 && !entry.review_completed) || entry.review_timed_out; if issues_present && !allow_issues { bad_issues.push(short_sha(&entry.commit_sha).to_string()); } if unreviewed && !allow_unreviewed { bad_unreviewed.push(short_sha(&entry.commit_sha).to_string()); } } if !bad_unreviewed.is_empty() { bail!( "Some queued commits do not have a clean review (timed out/missing): {}. Re-run review or use --allow-unreviewed.", bad_unreviewed.join(", ") ); } if !bad_issues.is_empty() { bail!( "Some queued commits have review issues: {}. Fix them or use --allow-issues.", bad_issues.join(", ") ); } } let head_sha = git_capture_in(repo_root, &["rev-parse", "HEAD"])?; let head_sha = head_sha.trim().to_string(); ensure_safe_upstream_for_commit_queue_push(repo_root, &head_sha, force)?; if git_try_in(repo_root, &["fetch", "--quiet"]).is_ok() { if let Ok(counts) = git_capture_in( repo_root, &["rev-list", "--left-right", "--count", "@{u}...HEAD"], ) { let parts: Vec<&str> = counts.split_whitespace().collect(); if parts.len() == 2 { let behind = parts[0].parse::<u64>().unwrap_or(0); if behind > 0 && !force { bail!( "Remote is ahead by {} commit(s). Run `f sync` or rebase, then re-approve.", behind ); } } } } let before_sha = git_capture_in(repo_root, &["rev-parse", "@{u}"]).ok(); let push_remote = config::preferred_git_remote_for_repo(repo_root); let push_branch = current_branch.trim().to_string(); print!("Pushing... "); io::stdout().flush()?; let mut pushed = false; match git_push_try_in(repo_root, &push_remote, &push_branch) { PushResult::Success => { println!("done"); pushed = true; } PushResult::NoRemoteRepo => { println!("skipped (no remote repo)"); } PushResult::RemoteAhead => { println!("failed (remote ahead)"); print!("Pulling with rebase... "); io::stdout().flush()?; match git_pull_rebase_try_in(repo_root, &push_remote, &push_branch) { Ok(_) => { println!("done"); print!("Pushing... "); io::stdout().flush()?; git_push_run_in(repo_root, &push_remote, &push_branch)?; println!("done"); pushed = true; } Err(_) => { println!("conflict!"); println!(); println!("Rebase conflict detected. Resolve manually:"); println!(" 1. Fix conflicts in the listed files"); println!(" 2. git add <files>"); println!(" 3. git rebase --continue"); println!(" 4. git push"); println!(); println!("Or abort with: git rebase --abort"); bail!("Rebase conflict - manual resolution required"); } } } } if pushed { if let (Some(before_sha), Ok(after_sha)) = ( before_sha, git_capture_in(repo_root, &["rev-parse", "HEAD"]), ) { let branch = current_branch.as_str(); let before_sha = before_sha.trim(); let after_sha = after_sha.trim(); let _ = undo::record_action( repo_root, undo::ActionType::Push, before_sha, after_sha, branch, true, Some(push_remote.as_str()), None, ); } let head_sha = git_capture_in(repo_root, &["rev-parse", "HEAD"]).unwrap_or_default(); let head_sha = head_sha.trim(); let mut approved = 0; let mut skipped = 0; for entry in &candidates { if git_is_ancestor(repo_root, &entry.commit_sha, head_sha) { if let Some(bookmark) = entry.review_bookmark.as_ref() { delete_review_bookmark(repo_root, bookmark); } remove_commit_queue_entry_by_entry(repo_root, entry)?; if let Ok(done) = todo::complete_review_timeout_todos(repo_root, &entry.review_todo_ids) { if done > 0 { println!("Auto-completed {} review follow-up todo(s).", done); } } approved += 1; } else { println!( "Skipped queued commit {} (not reachable from HEAD)", short_sha(&entry.commit_sha) ); skipped += 1; } } if !skipped_branch.is_empty() { println!( "Skipped {} queued commit(s) on other branches.", skipped_branch.len() ); } println!( "✓ Approved and pushed {} queued commit(s){}", approved, if skipped > 0 { " (some skipped)" } else { "" } ); } Ok(()) } fn commit_queue_entry_matches(entry: &CommitQueueEntry, hash: &str) -> bool { if entry.commit_sha.starts_with(hash) { return true; } if let Some(path) = entry.record_path.as_ref() { if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { return stem.starts_with(hash); } } false } fn refresh_queue_entry_commit(repo_root: &Path, entry: &mut CommitQueueEntry) -> Result<bool> { let Some(bookmark) = entry.review_bookmark.as_deref() else { return Ok(false); }; let Some(jj_root) = vcs::jj_root_if_exists(repo_root) else { return Ok(false); }; let Ok(output) = jj_capture_in( &jj_root, &["log", "-r", bookmark, "--no-graph", "-T", "commit_id"], ) else { return Ok(false); }; let new_sha = output .split_whitespace() .next() .unwrap_or_default() .trim() .to_string(); if new_sha.is_empty() || new_sha == entry.commit_sha { return Ok(false); } let old_sha = entry.commit_sha.clone(); let old_path = entry .record_path .clone() .unwrap_or_else(|| commit_queue_entry_path(repo_root, &entry.commit_sha)); entry.commit_sha = new_sha; let new_path = write_commit_queue_entry(repo_root, entry)?; if old_path != new_path && old_path.exists() { let _ = fs::remove_file(&old_path); } entry.record_path = Some(new_path); delete_rise_review_session(repo_root, &old_sha); if let Err(err) = write_rise_review_session(repo_root, entry) { debug!("failed to refresh rise review session: {}", err); } Ok(true) } fn current_upstream_ref(repo_root: &Path) -> Option<String> { git_capture_in( repo_root, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], ) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } fn is_ephemeral_upstream_ref(upstream: &str) -> bool { upstream.starts_with("origin/jj/keep/") || upstream.starts_with("origin/review/") || upstream.contains("/jj/keep/") || upstream.contains("/review/") } fn find_best_pr_upstream_candidate(repo_root: &Path, head_sha: &str) -> Option<String> { let refs = git_capture_in( repo_root, &[ "for-each-ref", "--format=%(refname:short)", "refs/remotes/origin/pr/", ], ) .ok()?; let mut best: Option<(u64, String)> = None; for candidate in refs.lines().map(str::trim).filter(|s| !s.is_empty()) { if !git_is_ancestor(repo_root, candidate, head_sha) { continue; } let distance = git_capture_in( repo_root, &["rev-list", "--count", &format!("{candidate}..{head_sha}")], ) .ok() .and_then(|s| s.trim().parse::<u64>().ok()) .unwrap_or(u64::MAX); match &best { Some((best_distance, _)) if *best_distance <= distance => {} _ => best = Some((distance, candidate.to_string())), } } best.map(|(_, candidate)| candidate) } fn ensure_safe_upstream_for_commit_queue_push( repo_root: &Path, head_sha: &str, force: bool, ) -> Result<()> { let upstream = current_upstream_ref(repo_root); if let Some(upstream) = upstream { if is_ephemeral_upstream_ref(&upstream) && !force { if let Some(candidate) = find_best_pr_upstream_candidate(repo_root, head_sha) { if candidate != upstream { println!( "Upstream {} looks ephemeral. Retargeting push upstream to {}.", upstream, candidate ); git_run_in(repo_root, &["branch", "--set-upstream-to", &candidate])?; } } else { bail!( "Current upstream {} looks ephemeral and no origin/pr/* candidate was found. Set upstream explicitly to your PR branch, or re-run with --force.", upstream ); } } return Ok(()); } if force { return Ok(()); } if let Some(candidate) = find_best_pr_upstream_candidate(repo_root, head_sha) { println!( "No upstream configured. Using {} as push upstream.", candidate ); git_run_in(repo_root, &["branch", "--set-upstream-to", &candidate])?; return Ok(()); } bail!( "No upstream configured and no origin/pr/* candidate found. Set upstream to your PR branch first, then re-run." ); } pub fn commit_queue_has_entries(repo_root: &Path) -> bool { let dir = commit_queue_dir(repo_root); if !dir.exists() { return false; } fs::read_dir(dir) .map(|entries| { entries .filter_map(|entry| entry.ok()) .any(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("json")) }) .unwrap_or(false) } pub fn commit_queue_has_entries_on_branch(repo_root: &Path, branch: &str) -> bool { let target = branch.trim(); if target.is_empty() { return commit_queue_has_entries(repo_root); } load_commit_queue_entries(repo_root) .map(|entries| entries.iter().any(|entry| entry.branch.trim() == target)) .unwrap_or_else(|_| commit_queue_has_entries(repo_root)) } pub fn commit_queue_has_entries_reachable_from_head(repo_root: &Path) -> bool { let head = match git_capture_in(repo_root, &["rev-parse", "HEAD"]) { Ok(value) => value.trim().to_string(), Err(_) => return commit_queue_has_entries(repo_root), }; if head.is_empty() { return commit_queue_has_entries(repo_root); } load_commit_queue_entries(repo_root) .map(|entries| { entries .iter() .any(|entry| git_is_ancestor(repo_root, &entry.commit_sha, &head)) }) .unwrap_or_else(|_| commit_queue_has_entries(repo_root)) } pub fn refresh_commit_queue(repo_root: &Path) -> Result<usize> { let mut entries = load_commit_queue_entries(repo_root)?; let mut updated = 0; for entry in &mut entries { if refresh_queue_entry_commit(repo_root, entry)? { updated += 1; } } Ok(updated) } fn queued_commit_patch(repo_root: &Path, commit_sha: &str) -> Result<String> { git_capture_in( repo_root, &["show", "--format=", "--patch", "--no-color", commit_sha], ) } fn with_temp_worktree_for_commit<T, F>(repo_root: &Path, commit_sha: &str, f: F) -> Result<T> where F: FnOnce(&Path) -> Result<T>, { let tmp = TempDir::new().context("create temp worktree dir")?; let worktree_path = tmp.path().join("repo"); let worktree_str = worktree_path.to_string_lossy().to_string(); git_run_in( repo_root, &["worktree", "add", "--detach", &worktree_str, commit_sha], )?; let result = f(&worktree_path); if let Err(err) = git_run_in(repo_root, &["worktree", "remove", "--force", &worktree_str]) { debug!( worktree = %worktree_str, error = %err, "failed to remove temp worktree for queue review" ); } result } fn run_codex_review_for_queued_commit( repo_root: &Path, commit_sha: &str, review_instructions: Option<&str>, ) -> Result<(ReviewResult, String)> { let diff = queued_commit_patch(repo_root, commit_sha)?; let review = with_temp_worktree_for_commit(repo_root, commit_sha, |worktree| { let parent = git_capture_in(worktree, &["rev-parse", "HEAD^"]) .context("queued root commit review is not supported yet")?; git_run_in(worktree, &["reset", "--mixed", parent.trim()])?; run_codex_review(&diff, None, review_instructions, worktree, CodexModel::High) })?; Ok((review, diff)) } fn append_unique_ids(dest: &mut Vec<String>, ids: Vec<String>) { let mut seen: HashSet<String> = dest.iter().cloned().collect(); for id in ids { if seen.insert(id.clone()) { dest.push(id); } } } fn review_queue_entry_with_codex( repo_root: &Path, entry: &mut CommitQueueEntry, review_instructions: Option<&str>, ) -> Result<()> { let (review, diff) = run_codex_review_for_queued_commit(repo_root, &entry.commit_sha, review_instructions)?; let model_label = CodexModel::High.as_codex_arg(); let reviewer_label = "codex"; let mut review_todo_ids = entry.review_todo_ids.clone(); if !env_flag("FLOW_REVIEW_ISSUES_TODOS_DISABLE") { if review.issues_found && !review.issues.is_empty() { let ids = todo::record_review_issues_as_todos( repo_root, &entry.commit_sha, &review.issues, review.summary.as_deref(), model_label, )?; append_unique_ids(&mut review_todo_ids, ids); } if review.timed_out { let issue = format!( "Re-run review: review timed out for commit {}", short_sha(&entry.commit_sha) ); let ids = todo::record_review_issues_as_todos( repo_root, &entry.commit_sha, &vec![issue], review.summary.as_deref(), model_label, )?; append_unique_ids(&mut review_todo_ids, ids); } else { let _ = todo::complete_review_timeout_todos(repo_root, &review_todo_ids); } } let review_run_id = flow_review_run_id(repo_root, &diff, model_label, reviewer_label); record_review_outputs_to_beads_rust( repo_root, &review, reviewer_label, model_label, Some(&entry.commit_sha), &review_run_id, ); entry.review_completed = true; entry.review_issues_found = review.issues_found; entry.review_timed_out = review.timed_out; entry.review_model = Some(model_label.to_string()); entry.review_reviewer = Some(reviewer_label.to_string()); entry.review_todo_ids = review_todo_ids; entry.review = format_review_body(&review); entry.summary = review.summary.as_ref().and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); let path = write_commit_queue_entry(repo_root, entry)?; entry.record_path = Some(path); let _ = write_rise_review_session(repo_root, entry); maybe_sync_queue_review_to_mirrors(repo_root, entry, &diff, &review, reviewer_label); Ok(()) } /// Mirror queued-review results to myflow/gitedit when the reviewed commit is the current HEAD. /// This keeps async `f commit --quick` reviews visible in mirrors without risking wrong SHA syncs /// when users review arbitrary queued commits from other branches. fn maybe_sync_queue_review_to_mirrors( repo_root: &Path, entry: &CommitQueueEntry, diff: &str, review: &ReviewResult, reviewer_label: &str, ) { let head_sha = match git_capture_in(repo_root, &["rev-parse", "HEAD"]) { Ok(sha) => sha.trim().to_string(), Err(err) => { debug!( error = %err, "skipping queue review mirror sync: failed to resolve HEAD" ); return; } }; if head_sha != entry.commit_sha { debug!( queue_commit = %entry.commit_sha, head_commit = %head_sha, "skipping queue review mirror sync: reviewed commit is not HEAD" ); return; } let sync_gitedit = gitedit_globally_enabled() && gitedit_mirror_enabled_for_commit(repo_root); let sync_myflow = myflow_mirror_enabled(repo_root); if !sync_gitedit && !sync_myflow { return; } let (sync_sessions, sync_window) = collect_sync_sessions_for_commit_with_window(repo_root); let review_data = GitEditReviewData { diff: Some(diff.to_string()), issues_found: review.issues_found, issues: review.issues.clone(), summary: review.summary.clone(), reviewer: Some(reviewer_label.to_string()), }; if sync_gitedit { sync_to_gitedit( repo_root, "commit_queue_review", &sync_sessions, None, Some(&review_data), ); } if sync_myflow { sync_to_myflow( repo_root, "commit_queue_review", &sync_sessions, Some(&sync_window), Some(&review_data), None, ); } } fn queue_flag_for_command(queue: CommitQueueMode) -> String { if queue.enabled { " --queue".to_string() } else if queue.override_flag == Some(false) { " --no-queue".to_string() } else { String::new() } } fn review_flag_for_command(queue: CommitQueueMode) -> String { if queue.open_review { " --review".to_string() } else { String::new() } } fn review_bookmark_prefix(repo_root: &Path) -> Option<String> { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(jj_cfg) = cfg.jj { if let Some(prefix) = jj_cfg.review_prefix { let trimmed = prefix.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } else { return None; } } } } } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { if let Some(jj_cfg) = cfg.jj { if let Some(prefix) = jj_cfg.review_prefix { let trimmed = prefix.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } else { return None; } } } } } Some("review".to_string()) } fn sanitize_review_branch(branch: &str) -> String { let mut out = String::new(); for ch in branch.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { out.push(ch); } else if ch == '/' || ch == '.' { out.push('-'); } } if out.is_empty() { "branch".to_string() } else { out } } fn create_review_bookmark(repo_root: &Path, commit_sha: &str, branch: &str) -> Result<String> { if env_flag("FLOW_COMMIT_QUEUE_JJ_DISABLE") { bail!("FLOW_COMMIT_QUEUE_JJ_DISABLE=1"); } let Some(prefix) = review_bookmark_prefix(repo_root) else { bail!("review prefix disabled"); }; let Some(jj_root) = vcs::jj_root_if_exists(repo_root) else { println!("ℹ️ jj workspace not found; skipping review bookmark creation."); bail!("jj workspace not available"); }; let branch_slug = sanitize_review_branch(branch); let base = format!("{}/{}-{}", prefix, branch_slug, short_sha(commit_sha)); let mut name = base.clone(); let mut index = 1; while jj_bookmark_exists(&jj_root, &name) { name = format!("{}-{}", base, index); index += 1; if index > 50 { bail!("too many review bookmarks with base {}", base); } } if let Err(err) = jj_run_in(&jj_root, &["bookmark", "create", &name, "-r", commit_sha]) { let msg = err.to_string().to_lowercase(); if msg.contains("commit not found") || msg.contains("current working-copy commit not found") || msg.contains("failed to load short-prefixes index") || msg.contains("unexpected error from store") || msg.contains("failed to check out a commit") { println!("⚠️ jj workspace appears corrupted; skipping review bookmark creation."); println!( " Fix: `jj git import` (or if still broken: `rm -rf .jj && jj git init --colocate`)" ); bail!("jj workspace corrupted"); } return Err(err); } println!("Queued review bookmark {}", name); Ok(name) } fn delete_review_bookmark(repo_root: &Path, bookmark: &str) { if let Some(jj_root) = vcs::jj_root_if_exists(repo_root) { let _ = jj_run_in(&jj_root, &["bookmark", "delete", bookmark]); } } fn jj_bookmark_exists(repo_root: &Path, name: &str) -> bool { let output = jj_capture_in(repo_root, &["bookmark", "list"]).unwrap_or_default(); for line in output.lines() { let trimmed = line.trim_start().trim_start_matches('*').trim(); let Some((token, _rest)) = trimmed.split_once(' ') else { continue; }; if token == name { return true; } } false } fn jj_run_in(repo_root: &Path, args: &[&str]) -> Result<()> { let output = Command::new(jj_bin()) .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let msg = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() }; bail!("jj {} failed: {}", args.join(" "), msg); } Ok(()) } fn jj_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new(jj_bin()) .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let msg = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() }; bail!("jj {} failed: {}", args.join(" "), msg); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn jj_bin() -> String { env::var("FLOW_JJ_BIN") .ok() .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .unwrap_or_else(|| "jj".to_string()) } fn ensure_gh_available() -> Result<()> { let status = Command::new("gh") .args(["--version"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .context("failed to run `gh` (GitHub CLI)")?; if !status.success() { bail!("`gh` is installed but not working"); } Ok(()) } fn gh_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("gh") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run gh {}", args.join(" ")))?; if !output.status.success() { bail!( "gh {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim() ); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn github_repo_from_remote_url(url: &str) -> Option<String> { let trimmed = url.trim().trim_end_matches('/'); if trimmed.is_empty() { return None; } // https://github.com/owner/repo(.git) if let Some(rest) = trimmed.strip_prefix("https://github.com/") { return Some(rest.trim_end_matches(".git").to_string()); } // git@github.com:owner/repo(.git) if let Some(rest) = trimmed.strip_prefix("git@github.com:") { return Some(rest.trim_end_matches(".git").to_string()); } None } fn resolve_github_repo(repo_root: &Path) -> Result<String> { // First try origin URL. if let Ok(url) = git_capture_in(repo_root, &["remote", "get-url", "origin"]) { if let Some(repo) = github_repo_from_remote_url(&url) { return Ok(repo); } } // Fallback: ask `gh` (works for GitHub Enterprise too if authenticated). let repo = gh_capture_in( repo_root, &[ "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner", ], ) .context("failed to resolve GitHub repo for current directory")?; let repo = repo.trim(); if repo.is_empty() { bail!( "unable to determine GitHub repo (origin URL not GitHub, and `gh repo view` returned empty)" ); } Ok(repo.to_string()) } fn sanitize_ref_component(input: &str) -> String { let mut out = String::new(); let mut last_sep = false; for ch in input.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { out.push(ch); last_sep = false; } else if !last_sep { out.push('-'); last_sep = true; } } out.trim_matches('-').to_string() } fn default_pr_head(entry: &CommitQueueEntry) -> String { if let Some(head) = entry .pr_head .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { return head.to_string(); } if let Some(bookmark) = entry .review_bookmark .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { return bookmark.to_string(); } // Fallback if jj bookmark wasn't created for some reason. format!( "pr/{}-{}", sanitize_ref_component(&entry.branch), short_sha(&entry.commit_sha) ) } fn ensure_pr_head_pushed(repo_root: &Path, head: &str, commit_sha: &str) -> Result<String> { // Prefer jj bookmarks when available. if which::which("jj").is_ok() { // Ensure bookmark points at the commit, then push it. // If jj is unhealthy (store/index/template issues), fall back to git push. let jj_result = (|| -> Result<()> { let set_output = Command::new("jj") .current_dir(repo_root) .args([ "bookmark", "set", head, "-r", commit_sha, "--allow-backwards", ]) .output() .context("failed to run jj bookmark set for PR head")?; if !set_output.status.success() { let stderr = String::from_utf8_lossy(&set_output.stderr); let stdout = String::from_utf8_lossy(&set_output.stdout); bail!( "jj bookmark set failed: {}", format!("{}\n{}", stderr.trim(), stdout.trim()).trim() ); } // We often push a brand new review/pr bookmark as the PR head. let push_output = Command::new("jj") .current_dir(repo_root) .args(["git", "push", "--bookmark", head, "--allow-new"]) .output() .context("failed to run jj git push for PR head")?; if !push_output.status.success() { let stderr = String::from_utf8_lossy(&push_output.stderr); let stdout = String::from_utf8_lossy(&push_output.stdout); bail!( "jj git push failed: {}", format!("{}\n{}", stderr.trim(), stdout.trim()).trim() ); } Ok(()) })(); if jj_result.is_ok() { // jj push uses the repo's configured/default git remote. // Keep plain branch head; gh can resolve this for same-repo pushes. return Ok(head.to_string()); } let jj_error = jj_result.unwrap_err().to_string(); let concise = jj_error .lines() .map(str::trim) .find(|line| !line.is_empty()) .unwrap_or("jj failed"); eprintln!( "⚠️ jj bookmark push failed ({}). Falling back to git branch push for PR head.", concise ); } // Fallback: push commit directly to a branch ref. // Try likely writable remotes first to support fork/upstream setups. let head_refspec = format!("{}:refs/heads/{}", commit_sha, head); let remotes = pr_push_remote_candidates(repo_root); if remotes.is_empty() { bail!("No git remotes configured; cannot push PR head {}", head); } let mut failures: Vec<String> = Vec::new(); for remote in remotes { let push_output = Command::new("git") .current_dir(repo_root) .args(["push", "-u", &remote, &head_refspec]) .output() .with_context(|| format!("failed to run git push for remote {remote}"))?; if push_output.status.success() { return Ok(pr_head_selector_for_remote(repo_root, &remote, head)); } let push_stderr = String::from_utf8_lossy(&push_output.stderr) .trim() .to_string(); let push_stdout = String::from_utf8_lossy(&push_output.stdout) .trim() .to_string(); // Branch exists/diverged: retry safely with force-with-lease on the same remote. let force_output = Command::new("git") .current_dir(repo_root) .args(["push", "--force-with-lease", &remote, &head_refspec]) .output() .with_context(|| format!("failed to run git force push for remote {remote}"))?; if force_output.status.success() { return Ok(pr_head_selector_for_remote(repo_root, &remote, head)); } let force_stderr = String::from_utf8_lossy(&force_output.stderr) .trim() .to_string(); failures.push(format!( "{remote}: push='{}' force='{}'{}", push_stderr, force_stderr, if push_stdout.is_empty() { String::new() } else { format!(" stdout='{}'", push_stdout) } )); } bail!( "failed to push PR head {} to any remote:\n{}", head, failures.join("\n") ); } fn pr_push_remote_candidates(repo_root: &Path) -> Vec<String> { let mut remotes: Vec<String> = git_capture_in(repo_root, &["remote"]) .unwrap_or_default() .lines() .map(str::trim) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect(); remotes.sort_by_key(|r| match r.as_str() { "fork" => 0u8, "origin" => 1u8, "upstream" => 3u8, _ => 2u8, }); remotes } fn pr_head_selector_for_remote(repo_root: &Path, remote: &str, head: &str) -> String { let Some(url) = git_capture_in(repo_root, &["remote", "get-url", remote]) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) else { return head.to_string(); }; if let Some((owner, _repo)) = parse_github_remote(&url) { return format!("{owner}:{head}"); } head.to_string() } fn extract_pr_url(text: &str) -> Option<String> { let re = Regex::new(r"https://github\\.com/[^/\\s]+/[^/\\s]+/pull/\\d+").ok()?; re.find(text).map(|m| m.as_str().to_string()) } fn pr_number_from_url(url: &str) -> Option<u64> { let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect(); parts.last()?.parse().ok() } fn split_head_selector(head: &str) -> (Option<&str>, &str) { let trimmed = head.trim(); if let Some((owner, branch)) = trimmed.split_once(':') { let owner = owner.trim(); let branch = branch.trim(); if !owner.is_empty() && !branch.is_empty() { return (Some(owner), branch); } } (None, trimmed) } fn gh_find_open_pr_by_head( repo_root: &Path, repo: &str, head: &str, ) -> Result<Option<(u64, String)>> { #[derive(Deserialize)] struct HeadOwner { login: String, } #[derive(Deserialize)] struct PrListItem { number: u64, url: String, #[serde(rename = "headRefName")] head_ref_name: String, #[serde(rename = "headRepositoryOwner")] head_repository_owner: Option<HeadOwner>, } let (owner_filter, branch) = split_head_selector(head); if branch.is_empty() { return Ok(None); } // gh --head matches by branch name; owner qualification must be filtered client-side. let out = gh_capture_in( repo_root, &[ "pr", "list", "--repo", repo, "--head", branch, "--state", "open", "--json", "number,url,headRefName,headRepositoryOwner", ], ) .unwrap_or_default(); let prs: Vec<PrListItem> = serde_json::from_str(out.trim()).unwrap_or_default(); for pr in prs { if pr.head_ref_name != branch { continue; } if let Some(owner) = owner_filter { let login = pr .head_repository_owner .as_ref() .map(|o| o.login.as_str()) .unwrap_or_default(); if !login.eq_ignore_ascii_case(owner) { continue; } } return Ok(Some((pr.number, pr.url))); } Ok(None) } fn gh_create_pr( repo_root: &Path, repo: &str, head: &str, base: &str, title: &str, body: &str, draft: bool, ) -> Result<(u64, String)> { let normalized_body = normalize_markdown_linebreaks(body); let mut args: Vec<&str> = vec![ "pr", "create", "--repo", repo, "--head", head, "--base", base, "--title", title, "--body", &normalized_body, ]; if draft { args.push("--draft"); } let output = Command::new("gh") .current_dir(repo_root) .args(&args) .output() .with_context(|| format!("failed to run gh {}", args.join(" ")))?; // gh can fail with "already exists" and still include the PR URL in stderr. let combined = format!( "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); if !output.status.success() { if let Some(url) = extract_pr_url(&combined) { let number = pr_number_from_url(&url) .ok_or_else(|| anyhow::anyhow!("failed to parse PR number from URL {}", url))?; return Ok((number, url)); } bail!( "gh {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim() ); } // gh typically prints the PR URL, but some versions/configs can produce no stdout. if let Some(url) = extract_pr_url(&combined) { let number = pr_number_from_url(&url) .ok_or_else(|| anyhow::anyhow!("failed to parse PR number from URL {}", url))?; return Ok((number, url)); } if let Some(found) = gh_find_open_pr_by_head(repo_root, repo, head)? { return Ok(found); } bail!( "failed to determine PR URL after creation (gh output had no URL and PR lookup by head returned empty)" ); } fn open_in_browser(url: &str) -> Result<()> { #[cfg(target_os = "macos")] { let status = Command::new("open").arg(url).status()?; if !status.success() { bail!("failed to open browser"); } return Ok(()); } #[cfg(not(target_os = "macos"))] { let status = Command::new("xdg-open").arg(url).status()?; if !status.success() { bail!("failed to open browser"); } Ok(()) } } fn commit_message_title_body(message: &str) -> (String, String) { let mut lines = message.lines(); let title = lines.next().unwrap_or("no title").trim().to_string(); let rest = lines.collect::<Vec<_>>().join("\n").trim().to_string(); (title, rest) } fn normalize_markdown_linebreaks(text: &str) -> String { let trimmed = text.trim(); // Guardrail: if body has escaped line breaks but no real newlines, decode it. // This prevents malformed PR bodies like "Summary\\n- item" on GitHub. if !trimmed.contains('\n') && trimmed.contains("\\n") { return trimmed.replace("\\r\\n", "\n").replace("\\n", "\n"); } trimmed.to_string() } pub fn run_commit_queue(cmd: CommitQueueCommand) -> Result<()> { ensure_git_repo()?; let repo_root = git_root_or_cwd(); ensure_commit_setup(&repo_root)?; let action = cmd.action.unwrap_or(CommitQueueAction::List); match action { CommitQueueAction::List => { let entries = load_commit_queue_entries(&repo_root)?; if entries.is_empty() { println!("No queued commits."); return Ok(()); } println!("Queued commits:"); for mut entry in entries { let _ = refresh_queue_entry_commit(&repo_root, &mut entry); let subject = entry.message.lines().next().unwrap_or("no message").trim(); let created_at = format_queue_created_at(&entry.created_at); let bookmark = entry .review_bookmark .as_ref() .map(|b| format!(" {}", b)) .unwrap_or_default(); println!( " {} {} {} {}{}", short_sha(&entry.commit_sha), entry.branch, created_at, subject, bookmark ); } } CommitQueueAction::Show { hash } => { let mut entry = resolve_commit_queue_entry(&repo_root, &hash)?; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); println!("Commit: {}", entry.commit_sha); println!("Branch: {}", entry.branch); println!("Queued: {}", entry.created_at); if let Some(bookmark) = entry.review_bookmark.as_ref() { println!("Review bookmark: {}", bookmark); } println!(); println!("Message:"); println!("────────────────────────────────────────"); println!("{}", entry.message.trim_end()); println!("────────────────────────────────────────"); let issues_present = entry.review_issues_found || entry .review .as_deref() .map(|s| !s.trim().is_empty()) .unwrap_or(false); if entry.review_timed_out { println!(); println!("Review: timed out or failed"); } if issues_present { if let Some(body) = entry .review .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { println!(); println!("Review issues:"); println!("{}", body); } } if !entry.review_todo_ids.is_empty() { println!(); println!("Todos: {}", entry.review_todo_ids.join(", ")); } if let Ok(stat) = git_capture_in( &repo_root, &["show", "--stat", "--format=", &entry.commit_sha], ) { if !stat.trim().is_empty() { println!(); println!("{}", stat.trim_end()); } } println!(); println!("Open diff UI:"); println!(" f commit-queue open {}", short_sha(&entry.commit_sha)); println!("Print diff:"); println!(" f commit-queue diff {}", short_sha(&entry.commit_sha)); } CommitQueueAction::Open { hash } => { let mut entry = resolve_commit_queue_entry(&repo_root, &hash)?; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); // Ensure the review session exists (Rise UI expects a review session file). let _ = write_rise_review_session(&repo_root, &entry); println!( "Opening queued commit {} in Rise app...", short_sha(&entry.commit_sha) ); open_review_in_rise(&repo_root, &entry.commit_sha); } CommitQueueAction::Diff { hash } => { let mut entry = resolve_commit_queue_entry(&repo_root, &hash)?; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); // Print a full patch (user can pipe to less -R). let patch = git_capture_in( &repo_root, &[ "show", "--color=always", "--patch", "--format=fuller", &entry.commit_sha, ], )?; // Avoid panicking on SIGPIPE (e.g. `... | head`). if let Err(err) = io::stdout().write_all(patch.trim_end().as_bytes()) { if err.kind() != io::ErrorKind::BrokenPipe { return Err(err).context("failed to write diff to stdout"); } return Ok(()); } if let Err(err) = io::stdout().write_all(b"\n") { if err.kind() != io::ErrorKind::BrokenPipe { return Err(err).context("failed to write diff newline to stdout"); } } } CommitQueueAction::Review { hashes, all } => { let mut entries = load_commit_queue_entries(&repo_root)?; if entries.is_empty() { println!("No queued commits."); return Ok(()); } for entry in &mut entries { let _ = refresh_queue_entry_commit(&repo_root, entry); } let mut targets: Vec<CommitQueueEntry> = Vec::new(); if !hashes.is_empty() { for hash in hashes { let matches: Vec<CommitQueueEntry> = entries .iter() .filter(|entry| commit_queue_entry_matches(entry, &hash)) .cloned() .collect(); match matches.len() { 0 => bail!("No queued commit matches {}", hash), 1 => targets.push(matches[0].clone()), _ => bail!("Multiple queued commits match {}. Use a longer hash.", hash), } } } else if all { targets = entries; } else { let current_branch = git_capture_in(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()); targets = entries .into_iter() .filter(|entry| entry.branch.trim() == current_branch.trim()) .collect(); } if targets.is_empty() { println!("No queued commits selected for review."); return Ok(()); } let review_instructions = get_review_instructions(&repo_root); let mut clean = 0usize; let mut with_issues = 0usize; let mut timed_out = 0usize; let mut failed = 0usize; for mut entry in targets { println!( "==> Reviewing queued commit {} ({}) with Codex...", short_sha(&entry.commit_sha), entry.branch ); match review_queue_entry_with_codex( &repo_root, &mut entry, review_instructions.as_deref(), ) { Ok(()) => { if entry.review_timed_out { timed_out += 1; println!( " ⚠ Review timed out again for {}", short_sha(&entry.commit_sha) ); } else if entry.review_issues_found { with_issues += 1; println!( " ⚠ Review found issue(s) for {}", short_sha(&entry.commit_sha) ); } else { clean += 1; println!(" ✓ Review clean for {}", short_sha(&entry.commit_sha)); } if !entry.review_todo_ids.is_empty() { match todo::count_open_todos(&repo_root, &entry.review_todo_ids) { Ok(open) => { if open > 0 { println!( " ↳ {} open review todo(s): {}", open, entry.review_todo_ids.join(", ") ); } else { println!(" ↳ review todos accounted for"); } } Err(err) => println!(" ↳ todo status check failed: {}", err), } } } Err(err) => { failed += 1; println!( " ✗ Failed to review {}: {}", short_sha(&entry.commit_sha), err ); } } } println!( "Review refresh summary: clean={}, issues={}, timed_out={}, failed={}", clean, with_issues, timed_out, failed ); if failed > 0 { bail!("Some queued commit reviews failed. Resolve errors and re-run."); } } CommitQueueAction::Approve { all, hash, queue_if_missing, mark_reviewed, force, allow_issues, allow_unreviewed, } => { if all { if hash.is_some() { bail!( "--all cannot be combined with HASH. Use `f commit-queue approve --all`." ); } if queue_if_missing { eprintln!("note: --queue-if-missing is ignored when using --all"); } if mark_reviewed { eprintln!("note: --mark-reviewed is ignored when using --all"); } return approve_all_queued_commits( &repo_root, force, allow_issues, allow_unreviewed, ); } git_guard::ensure_clean_for_push(&repo_root)?; let auto_mode = hash.is_none(); let target_hash = match hash { Some(value) => value, None => git_capture_in(&repo_root, &["rev-parse", "--verify", "HEAD"])? .trim() .to_string(), }; let effective_queue_if_missing = queue_if_missing || auto_mode; let effective_mark_reviewed = mark_reviewed || auto_mode; let effective_allow_unreviewed = allow_unreviewed || auto_mode; let mut entry = match resolve_commit_queue_entry(&repo_root, &target_hash) { Ok(entry) => entry, Err(err) => { let no_match = err .to_string() .starts_with(&format!("No queued commit matches {}", target_hash)); if effective_queue_if_missing && no_match { let entry = queue_existing_commit_for_approval( &repo_root, &target_hash, effective_mark_reviewed, )?; println!( "Queued {} from git history for approval{}.", short_sha(&entry.commit_sha), if effective_mark_reviewed { " (marked manually reviewed)" } else { "" } ); entry } else { return Err(err); } } }; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); let issues_present = entry.review_issues_found || entry .review .as_deref() .map(|s| !s.trim().is_empty()) .unwrap_or(false); let unreviewed = entry.version >= 2 && !entry.review_completed; if issues_present && !allow_issues && !force { bail!( "Queued commit {} has review issues. Fix them, or re-run with --allow-issues.", short_sha(&entry.commit_sha) ); } if unreviewed && !effective_allow_unreviewed && !force { bail!( "Queued commit {} does not have a clean review (missing). Re-run review, or re-run with --allow-unreviewed.", short_sha(&entry.commit_sha) ); } if entry.review_timed_out && !force { eprintln!( "note: review timed out for {}; approving anyway (re-run `f commit-queue review {}` if you want a full review)", short_sha(&entry.commit_sha), short_sha(&entry.commit_sha) ); } let head_sha = git_capture_in(&repo_root, &["rev-parse", "HEAD"])?; let head_sha = head_sha.trim(); if head_sha != entry.commit_sha && !force { bail!( "Queued commit {} is not at HEAD (current HEAD is {}). Checkout the commit or re-run with --force.", short_sha(&entry.commit_sha), short_sha(head_sha) ); } let current_branch = git_capture_in(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()); if current_branch.trim() != entry.branch && !force { bail!( "Queued commit was created on branch {} but current branch is {}. Checkout the branch or re-run with --force.", entry.branch, current_branch.trim() ); } ensure_safe_upstream_for_commit_queue_push(&repo_root, head_sha, force)?; if git_try_in(&repo_root, &["fetch", "--quiet"]).is_ok() { if let Ok(counts) = git_capture_in( &repo_root, &["rev-list", "--left-right", "--count", "@{u}...HEAD"], ) { let parts: Vec<&str> = counts.split_whitespace().collect(); if parts.len() == 2 { let behind = parts[0].parse::<u64>().unwrap_or(0); if behind > 0 && !force { bail!( "Remote is ahead by {} commit(s). Run `f sync` or rebase, then re-approve.", behind ); } } } } let before_sha = git_capture_in(&repo_root, &["rev-parse", "@{u}"]).ok(); let push_remote = config::preferred_git_remote_for_repo(&repo_root); let push_branch = current_branch.trim().to_string(); print!("Pushing... "); io::stdout().flush()?; let mut pushed = false; match git_push_try_in(&repo_root, &push_remote, &push_branch) { PushResult::Success => { println!("done"); pushed = true; } PushResult::NoRemoteRepo => { println!("skipped (no remote repo)"); } PushResult::RemoteAhead => { println!("failed (remote ahead)"); print!("Pulling with rebase... "); io::stdout().flush()?; match git_pull_rebase_try_in(&repo_root, &push_remote, &push_branch) { Ok(_) => { println!("done"); print!("Pushing... "); io::stdout().flush()?; git_push_run_in(&repo_root, &push_remote, &push_branch)?; println!("done"); pushed = true; } Err(_) => { println!("conflict!"); println!(); println!("Rebase conflict detected. Resolve manually:"); println!(" 1. Fix conflicts in the listed files"); println!(" 2. git add <files>"); println!(" 3. git rebase --continue"); println!(" 4. git push"); println!(); println!("Or abort with: git rebase --abort"); bail!("Rebase conflict - manual resolution required"); } } } } if pushed { if let (Some(before_sha), Ok(after_sha)) = ( before_sha, git_capture_in(&repo_root, &["rev-parse", "HEAD"]), ) { let branch = current_branch.trim(); let before_sha = before_sha.trim(); let after_sha = after_sha.trim(); let _ = undo::record_action( &repo_root, undo::ActionType::Push, before_sha, after_sha, branch, true, Some(push_remote.as_str()), Some(&entry.message), ); } if let Some(bookmark) = entry.review_bookmark.as_ref() { delete_review_bookmark(&repo_root, bookmark); } remove_commit_queue_entry_by_entry(&repo_root, &entry)?; if let Ok(done) = todo::complete_review_timeout_todos(&repo_root, &entry.review_todo_ids) { if done > 0 { println!("Auto-completed {} review follow-up todo(s).", done); } } println!("✓ Approved and pushed {}", short_sha(&entry.commit_sha)); } } CommitQueueAction::ApproveAll { force, allow_issues, allow_unreviewed, } => approve_all_queued_commits(&repo_root, force, allow_issues, allow_unreviewed)?, CommitQueueAction::Drop { hash } => { let mut entry = resolve_commit_queue_entry(&repo_root, &hash)?; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); if let Some(bookmark) = entry.review_bookmark.as_ref() { delete_review_bookmark(&repo_root, bookmark); } remove_commit_queue_entry_by_entry(&repo_root, &entry)?; println!("Dropped queued commit {}", short_sha(&entry.commit_sha)); } CommitQueueAction::PrCreate { hash, base, draft, open, } => { ensure_gh_available()?; let repo = resolve_github_repo(&repo_root)?; let mut entry = resolve_commit_queue_entry(&repo_root, &hash)?; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); let head = default_pr_head(&entry); let gh_head = ensure_pr_head_pushed(&repo_root, &head, &entry.commit_sha)?; let (number, url) = if let Some(found) = gh_find_open_pr_by_head(&repo_root, &repo, &gh_head)? { found } else { let (title, body_rest) = commit_message_title_body(&entry.message); let mut body = String::new(); if !body_rest.is_empty() { body.push_str(&body_rest); body.push_str("\n\n"); } if let Some(summary) = entry .summary .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { body.push_str("Review summary:\n"); body.push_str(summary); body.push('\n'); } gh_create_pr( &repo_root, &repo, &gh_head, &base, &title, body.trim(), draft, )? }; entry.pr_number = Some(number); entry.pr_url = Some(url.clone()); entry.pr_head = Some(head.clone()); entry.pr_base = Some(base.clone()); let _ = write_commit_queue_entry(&repo_root, &entry); println!("PR: {}", url); if open { let _ = open_in_browser(&url); } } CommitQueueAction::PrOpen { hash, base } => { ensure_gh_available()?; let repo = resolve_github_repo(&repo_root)?; let mut entry = resolve_commit_queue_entry(&repo_root, &hash)?; let _ = refresh_queue_entry_commit(&repo_root, &mut entry); let head = default_pr_head(&entry); let url = if let Some(url) = entry .pr_url .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { url.to_string() } else if let Some((_n, url)) = gh_find_open_pr_by_head(&repo_root, &repo, &head)? { url } else { // Create it if missing (as draft). let gh_head = ensure_pr_head_pushed(&repo_root, &head, &entry.commit_sha)?; let (title, body_rest) = commit_message_title_body(&entry.message); let (number, url) = if let Some(found) = gh_find_open_pr_by_head(&repo_root, &repo, &gh_head)? { found } else { gh_create_pr( &repo_root, &repo, &gh_head, &base, &title, body_rest.trim(), true, )? }; entry.pr_number = Some(number); entry.pr_url = Some(url.clone()); entry.pr_head = Some(head.clone()); entry.pr_base = Some(base.clone()); let _ = write_commit_queue_entry(&repo_root, &entry); url }; println!("{}", url); let _ = open_in_browser(&url); } } Ok(()) } pub fn run_pr(opts: PrOpts) -> Result<()> { let args = normalize_pr_args(&opts.args); if let Some(feedback) = parse_pr_feedback_args(&args)? { let repo_root = if feedback.selector.is_some() { std::env::current_dir().context("failed to resolve current directory")? } else { ensure_git_repo()?; let repo_root = git_root_or_cwd(); ensure_commit_setup(&repo_root)?; repo_root }; return run_pr_feedback(&repo_root, feedback); } ensure_git_repo()?; let repo_root = git_root_or_cwd(); ensure_commit_setup(&repo_root)?; match args.as_slice() { // Convenience: `f pr open` opens the PR for the current branch (or queued commit) without // creating a new commit. [a] if a == "open" => return run_pr_open(&repo_root, &opts), // Convenience: `f pr open edit` opens a local markdown file in Zed Preview and syncs PR // title/body on save. [a, b] if a == "open" && b == "edit" => return run_pr_open_edit(&repo_root, &opts), _ => {} } if !opts.paths.is_empty() && (opts.no_commit || opts.hash.is_some()) { bail!("--path cannot be used with --no-commit or --hash"); } let should_commit = !opts.no_commit && opts.hash.is_none(); if should_commit { let queue = resolve_commit_queue_mode(true, false); let review_selection = resolve_review_selection_v2(false, None); let message = if args.is_empty() { None } else { Some(args.join(" ")) }; run_with_check_sync( true, false, review_selection, message.as_deref(), 1000, false, queue, false, &opts.paths, CommitGateOverrides::default(), )?; } let hash = if let Some(hash) = opts.hash { hash } else { let _ = refresh_commit_queue(&repo_root); let mut entries = load_commit_queue_entries(&repo_root)?; let Some(entry) = entries.pop() else { bail!( "Commit queue is empty. Run `f pr \"message\"` or queue a commit first with `f commit --queue`." ); }; entry.commit_sha }; run_commit_queue(CommitQueueCommand { action: Some(CommitQueueAction::PrCreate { hash, base: opts.base, draft: opts.draft, open: !opts.no_open, }), }) } fn run_pr_open(repo_root: &Path, opts: &PrOpts) -> Result<()> { ensure_gh_available()?; let repo = resolve_github_repo(repo_root)?; // Prefer opening based on the current git branch name (most intuitive UX). let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); if !branch.is_empty() && branch != "HEAD" { if let Some((_n, url)) = gh_find_open_pr_by_head(repo_root, &repo, &branch)? { println!("PR: {}", url); if !opts.no_open { let _ = open_in_browser(&url); } return Ok(()); } } // Fallback: open based on queued commit (by explicit hash, by HEAD SHA, or latest entry). let hash = if let Some(hash) = opts.hash.clone() { hash } else { let head_sha = git_capture_in(repo_root, &["rev-parse", "HEAD"]) .unwrap_or_default() .trim() .to_string(); let _ = refresh_commit_queue(repo_root); let mut entries = load_commit_queue_entries(repo_root)?; if entries.is_empty() { bail!("No PR found for current branch and commit queue is empty."); } if !head_sha.is_empty() { if let Some(entry) = entries.iter().rev().find(|e| e.commit_sha == head_sha) { entry.commit_sha.clone() } else { entries.pop().unwrap().commit_sha } } else { entries.pop().unwrap().commit_sha } }; // Reuse the commit queue PR-open behavior (creates draft if missing). run_commit_queue(CommitQueueCommand { action: Some(CommitQueueAction::PrOpen { hash, base: opts.base.clone(), }), }) } fn normalize_pr_args(args: &[String]) -> Vec<String> { let mut normalized = Vec::new(); for a in args { let t = a.trim(); if !t.is_empty() { normalized.push(t.to_string()); } } normalized } #[derive(Debug, Clone)] struct PrFeedbackCommand { selector: Option<String>, record_todos: bool, show_full: bool, open_cursor: bool, } #[derive(Debug, Clone, Serialize)] struct PrFeedbackItem { external_ref: String, source: &'static str, author: String, body: String, url: String, thread_id: Option<String>, path: Option<String>, line: Option<u64>, review_state: Option<String>, diff_hunk: Option<String>, } #[derive(Debug, Serialize)] struct PrFeedbackSnapshot { repo: String, pr_number: u64, pr_url: String, pr_title: String, trace_id: String, generated_at: String, reviews_count: usize, review_comments_count: usize, issue_comments_count: usize, review_state_counts: HashMap<String, usize>, items: Vec<PrFeedbackItem>, } fn new_pr_feedback_trace_id() -> String { Uuid::new_v4().simple().to_string() } #[derive(Debug, Deserialize)] struct GhApiUser { login: String, } #[derive(Debug, Deserialize)] struct GhPrFeedbackSummary { number: u64, url: String, } #[derive(Debug, Deserialize)] struct GhPrTitleSummary { title: String, } #[derive(Debug, Deserialize)] struct GhPrReviewComment { id: u64, #[serde(default)] body: String, #[serde(default)] html_url: String, #[serde(default)] path: Option<String>, #[serde(default)] line: Option<u64>, #[serde(default)] diff_hunk: Option<String>, #[serde(default)] in_reply_to_id: Option<u64>, user: GhApiUser, } #[derive(Debug, Deserialize)] struct GhIssueComment { id: u64, #[serde(default)] body: String, #[serde(default)] html_url: String, user: GhApiUser, } #[derive(Debug, Deserialize)] struct GhReview { id: u64, #[serde(default)] body: String, #[serde(default)] state: String, #[serde(default)] html_url: String, user: GhApiUser, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadsResponse { data: GhGraphqlReviewThreadsData, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadsData { repository: GhGraphqlReviewThreadsRepository, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadsRepository { #[serde(rename = "pullRequest")] pull_request: GhGraphqlReviewThreadsPullRequest, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadsPullRequest { #[serde(rename = "reviewThreads")] review_threads: GhGraphqlReviewThreadsConnection, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadsConnection { nodes: Vec<GhGraphqlReviewThreadNode>, #[serde(rename = "pageInfo")] page_info: GhGraphqlPageInfo, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadNode { id: String, comments: GhGraphqlReviewThreadComments, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadComments { nodes: Vec<GhGraphqlReviewThreadCommentNode>, } #[derive(Debug, Deserialize)] struct GhGraphqlReviewThreadCommentNode { url: String, } #[derive(Debug, Deserialize)] struct GhGraphqlPageInfo { #[serde(rename = "hasNextPage")] has_next_page: bool, #[serde(rename = "endCursor")] end_cursor: Option<String>, } #[derive(Debug)] struct LoadedPrFeedback { repo: String, pr_number: u64, pr_url: String, pr_title: String, reviews: Vec<GhReview>, review_comments: Vec<GhPrReviewComment>, issue_comments: Vec<GhIssueComment>, items: Vec<PrFeedbackItem>, } #[derive(Debug)] struct PrFeedbackArtifacts { snapshot_path: PathBuf, snapshot_json_path: PathBuf, review_plan_path: PathBuf, review_rules_path: PathBuf, kit_system_path: PathBuf, } fn parse_pr_feedback_args(args: &[String]) -> Result<Option<PrFeedbackCommand>> { if args.first().map(|s| s.as_str()) != Some("feedback") { return Ok(None); } let mut selector: Option<String> = None; let mut record_todos = false; let mut show_full = true; let mut open_cursor = false; for token in args.iter().skip(1) { match token.as_str() { "--todo" | "todo" => record_todos = true, "--full" | "full" => show_full = true, "--compact" | "compact" => show_full = false, "--cursor" | "cursor" => open_cursor = true, "--help" | "-h" => { return Ok(Some(PrFeedbackCommand { selector: Some("--help".to_string()), record_todos: false, show_full: true, open_cursor: false, })); } _ if token.starts_with("--") => { bail!("unknown `f pr feedback` option: {token}"); } _ => { if selector.is_some() { bail!("multiple PR selectors provided. Use exactly one selector."); } selector = Some(token.clone()); } } } Ok(Some(PrFeedbackCommand { selector, record_todos, show_full, open_cursor, })) } fn parse_github_pr_url(input: &str) -> Option<(String, u64)> { let trimmed = input.trim().trim_end_matches('/'); let prefix = "https://github.com/"; let rest = trimmed.strip_prefix(prefix)?; let mut parts = rest.split('/'); let owner = parts.next()?.trim(); let repo = parts.next()?.trim(); let kind = parts.next()?.trim(); let number = parts.next()?.trim(); if owner.is_empty() || repo.is_empty() || kind != "pull" { return None; } let number = number.parse::<u64>().ok()?; Some((format!("{owner}/{repo}"), number)) } fn resolve_current_pr_for_feedback(repo_root: &Path, repo: &str) -> Result<(u64, String)> { let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); if !branch.is_empty() && branch != "HEAD" { if let Some((number, url)) = gh_find_open_pr_by_head(repo_root, repo, &branch)? { return Ok((number, url)); } } let out = gh_capture_in( repo_root, &["pr", "view", "--repo", repo, "--json", "number,url"], )?; let parsed: GhPrFeedbackSummary = serde_json::from_str(out.trim()) .context("failed to parse gh pr view output while resolving current PR")?; Ok((parsed.number, parsed.url)) } fn gh_api_json_in<T: DeserializeOwned>(repo_root: &Path, endpoint: &str) -> Result<T> { let out = gh_capture_in(repo_root, &["api", endpoint])?; serde_json::from_str(out.trim()) .with_context(|| format!("failed to parse GitHub API response for `{endpoint}`")) } fn gh_review_thread_ids_by_comment_url( repo_root: &Path, repo: &str, pr_number: u64, ) -> Result<HashMap<String, String>> { let (owner, repo_name) = repo .split_once('/') .with_context(|| format!("invalid GitHub repo `{repo}`"))?; let query = r#"query FlowPrReviewThreads($owner: String!, $repo: String!, $prNumber: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { reviewThreads(first: 100, after: $cursor) { nodes { id comments(first: 100) { nodes { url } } } pageInfo { hasNextPage endCursor } } } } }"#; let mut by_url = HashMap::new(); let mut cursor: Option<String> = None; loop { let mut args = vec![ "api".to_string(), "graphql".to_string(), "-f".to_string(), format!("query={query}"), "-F".to_string(), format!("owner={owner}"), "-F".to_string(), format!("repo={repo_name}"), "-F".to_string(), format!("prNumber={pr_number}"), ]; if let Some(value) = cursor.as_ref() { args.push("-F".to_string()); args.push(format!("cursor={value}")); } let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); let out = gh_capture_in(repo_root, &arg_refs)?; let parsed: GhGraphqlReviewThreadsResponse = serde_json::from_str(out.trim()) .context("failed to parse GitHub GraphQL review thread response")?; for thread in parsed .data .repository .pull_request .review_threads .nodes { for comment in thread.comments.nodes { let url = comment.url.trim(); if !url.is_empty() { by_url.insert(url.to_string(), thread.id.clone()); } } } let page_info = parsed.data.repository.pull_request.review_threads.page_info; if !page_info.has_next_page { break; } cursor = page_info.end_cursor; if cursor.is_none() { break; } } Ok(by_url) } fn pr_feedback_external_ref(repo: &str, pr_number: u64, source: &str, source_id: u64) -> String { let mut hasher = Sha1::new(); hasher.update(repo.as_bytes()); hasher.update(b":"); hasher.update(pr_number.to_string().as_bytes()); hasher.update(b":"); hasher.update(source.as_bytes()); hasher.update(b":"); hasher.update(source_id.to_string().as_bytes()); let hex = hex::encode(hasher.finalize()); let short = hex.get(..12).unwrap_or(&hex); format!("flow-pr-feedback-{short}") } fn compact_single_line(text: &str, max_chars: usize) -> String { let first = text .lines() .map(str::trim) .find(|line| !line.is_empty()) .unwrap_or("") .replace('\t', " "); if first.chars().count() <= max_chars { return first; } let mut out = String::new(); for (idx, ch) in first.chars().enumerate() { if idx >= max_chars.saturating_sub(3) { out.push_str("..."); break; } out.push(ch); } out } fn pr_feedback_todo_title(pr_number: u64, item: &PrFeedbackItem) -> String { let snippet = compact_single_line(&item.body, 90); let mut title = format!("PR #{pr_number} {}: {}", item.source, snippet); if title.trim().is_empty() { title = format!("PR #{pr_number} {} feedback", item.source); } title } fn feedback_location_label(item: &PrFeedbackItem) -> Option<String> { match (item.path.as_deref(), item.line) { (Some(path), Some(line)) => Some(format!("{path}:{line}")), (Some(path), None) => Some(path.to_string()), _ => None, } } fn feedback_review_state_label(item: &PrFeedbackItem) -> Option<String> { item.review_state .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.to_ascii_uppercase()) } fn compact_diff_hunk(diff_hunk: &str, max_lines: usize, max_chars: usize) -> String { let mut out = Vec::new(); let mut char_count = 0usize; for line in diff_hunk.lines().take(max_lines) { let trimmed = line.trim_end(); if trimmed.is_empty() { continue; } let next_len = trimmed.chars().count(); if char_count + next_len > max_chars { break; } out.push(trimmed.to_string()); char_count += next_len; } let mut rendered = out.join("\n"); if diff_hunk.lines().count() > max_lines || diff_hunk.chars().count() > max_chars { if !rendered.is_empty() { rendered.push('\n'); } rendered.push_str("..."); } rendered } fn compact_pr_feedback_context_block(value: &str, max_lines: usize, max_chars: usize) -> String { let mut rendered = String::new(); let mut char_count = 0usize; for line in value.lines().take(max_lines) { let trimmed = line.trim_end(); let next_len = trimmed.chars().count(); if char_count + next_len > max_chars { break; } if !rendered.is_empty() { rendered.push('\n'); } rendered.push_str(trimmed); char_count += next_len; } if value.lines().count() > max_lines || value.chars().count() > max_chars { if !rendered.is_empty() { rendered.push('\n'); } rendered.push_str("..."); } rendered } fn command_on_path(command: &str) -> bool { let Some(path_os) = env::var_os("PATH") else { return false; }; env::split_paths(&path_os).any(|dir| dir.join(command).is_file()) } fn cursor_review_open_command(selector: &str, compact: bool, open_cursor: bool) -> String { let mut parts = vec![ "f".to_string(), "pr".to_string(), "feedback".to_string(), selector.to_string(), ]; if compact { parts.push("--compact".to_string()); } if open_cursor { parts.push("--cursor".to_string()); } parts.join(" ") } fn open_cursor_review_bundle( workspace_root: &Path, review_plan_path: &Path, review_rules_path: &Path, kit_system_path: &Path, background: bool, ) -> Result<()> { let mut command = if cfg!(target_os = "macos") || !command_on_path("cursor") { let mut command = Command::new("open"); if background { command.arg("-g"); } command.arg("-a").arg("Cursor"); command } else { Command::new("cursor") }; command .arg(workspace_root) .arg(review_plan_path) .arg(review_rules_path) .arg(kit_system_path) .stdout(Stdio::null()) .stderr(Stdio::null()); let _status = command.status()?; Ok(()) } fn pr_feedback_snapshot_json_path(repo_root: &Path, pr_number: u64) -> Result<PathBuf> { let dir = repo_root.join(".ai").join("reviews"); fs::create_dir_all(&dir)?; Ok(dir.join(format!("pr-feedback-{pr_number}.json"))) } fn pr_feedback_plan_root() -> PathBuf { if let Some(root) = env::var_os("FLOW_PR_FEEDBACK_PLAN_ROOT").map(PathBuf::from) { return root; } dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("plan") .join("review") } fn pr_feedback_repo_slug(repo: &str) -> String { let mut out = String::new(); let mut last_dash = false; for ch in repo.chars() { let mapped = if ch.is_ascii_alphanumeric() { last_dash = false; ch.to_ascii_lowercase() } else { if last_dash { continue; } last_dash = true; '-' }; out.push(mapped); } out.trim_matches('-').to_string() } fn pr_feedback_review_plan_path_at(root: &Path, repo: &str, pr_number: u64) -> PathBuf { root.join(format!( "{}-pr-{}-feedback.md", pr_feedback_repo_slug(repo), pr_number )) } fn pr_feedback_kit_system_path_at(root: &Path, repo: &str, pr_number: u64) -> PathBuf { root.join(format!( "{}-pr-{}-kit-system.md", pr_feedback_repo_slug(repo), pr_number )) } fn pr_feedback_review_rules_path_at(root: &Path, repo: &str, pr_number: u64) -> PathBuf { root.join(format!( "{}-pr-{}-review-rules.md", pr_feedback_repo_slug(repo), pr_number )) } fn canonical_review_rules_doc_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("docs") .join("kit") .join("review-rules.md") } fn format_review_state_counts(reviews: &[GhReview]) -> String { let mut entries: Vec<(String, usize)> = review_state_counts_map(reviews).into_iter().collect(); if entries.is_empty() { return "none".to_string(); } entries.sort_by(|a, b| a.0.cmp(&b.0)); entries .into_iter() .map(|(state, count)| format!("{state}:{count}")) .collect::<Vec<_>>() .join(", ") } fn review_state_counts_map(reviews: &[GhReview]) -> HashMap<String, usize> { let mut counts: HashMap<String, usize> = HashMap::new(); for review in reviews { let key = if review.state.trim().is_empty() { "UNKNOWN".to_string() } else { review.state.trim().to_ascii_uppercase() }; *counts.entry(key).or_insert(0) += 1; } counts } fn record_pr_feedback_todos( repo_root: &Path, repo: &str, pr_number: u64, items: &[PrFeedbackItem], ) -> Result<Vec<String>> { let (path, mut todos) = todo::load_items_at_root(repo_root)?; let mut existing_refs = HashSet::new(); for todo_item in &todos { if let Some(ext) = todo_item .external_ref .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { existing_refs.insert(ext.to_string()); } } let mut created = Vec::new(); let now = chrono::Utc::now().to_rfc3339(); for item in items { if existing_refs.contains(&item.external_ref) { continue; } let id = Uuid::new_v4().simple().to_string(); let mut note = String::new(); note.push_str("Source: GitHub PR feedback\n"); note.push_str("Repo: "); note.push_str(repo); note.push('\n'); note.push_str("PR: "); note.push_str(&pr_number.to_string()); note.push('\n'); note.push_str("Type: "); note.push_str(item.source); note.push('\n'); note.push_str("Author: "); note.push_str(&item.author); note.push('\n'); if let Some(location) = feedback_location_label(item) { note.push_str("Location: "); note.push_str(&location); note.push('\n'); } note.push_str("Link: "); note.push_str(&item.url); note.push('\n'); note.push('\n'); note.push_str(item.body.trim()); todos.push(todo::TodoItem { id: id.clone(), title: pr_feedback_todo_title(pr_number, item), status: "pending".to_string(), created_at: now.clone(), updated_at: None, note: Some(note), session: None, external_ref: Some(item.external_ref.clone()), priority: Some(todo::parse_priority_from_issue(&item.body)), }); existing_refs.insert(item.external_ref.clone()); created.push(id); } if !created.is_empty() { todo::save_items(&path, &todos)?; } Ok(created) } fn write_pr_feedback_snapshot( repo_root: &Path, repo: &str, pr_number: u64, pr_url: &str, trace_id: &str, items: &[PrFeedbackItem], ) -> Result<PathBuf> { let dir = repo_root.join(".ai").join("reviews"); fs::create_dir_all(&dir)?; let path = dir.join(format!("pr-feedback-{pr_number}.md")); let mut out = String::new(); out.push_str("# PR Feedback\n\n"); out.push_str("- Repo: `"); out.push_str(repo); out.push_str("`\n"); out.push_str("- PR: #"); out.push_str(&pr_number.to_string()); out.push('\n'); out.push_str("- URL: "); out.push_str(pr_url); out.push('\n'); out.push_str("- Trace ID: `"); out.push_str(trace_id); out.push_str("`\n"); out.push_str("- Generated: "); out.push_str(&chrono::Utc::now().to_rfc3339()); out.push('\n'); out.push('\n'); if items.is_empty() { out.push_str("No actionable text feedback found.\n"); } else { out.push_str("## Actionable Items\n\n"); for (idx, item) in items.iter().enumerate() { out.push_str(&(idx + 1).to_string()); out.push_str(". ["); out.push_str(item.source); out.push_str("] "); out.push_str(&item.author); if let Some(location) = feedback_location_label(item) { out.push_str(" ("); out.push_str(&location); out.push(')'); } if let Some(state) = feedback_review_state_label(item) { out.push_str(" ["); out.push_str(&state); out.push(']'); } out.push('\n'); out.push_str(" "); out.push_str(item.body.trim()); out.push('\n'); out.push_str(" "); out.push_str(&item.url); out.push('\n'); if let Some(diff_hunk) = item .diff_hunk .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { out.push('\n'); out.push_str(" ```diff\n"); for line in compact_diff_hunk(diff_hunk, 20, 1000).lines() { out.push_str(" "); out.push_str(line); out.push('\n'); } out.push_str(" ```\n"); } out.push('\n'); } } fs::write(&path, out)?; Ok(path) } fn write_pr_feedback_snapshot_json(snapshot: &PrFeedbackSnapshot, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let bytes = serde_json::to_vec_pretty(snapshot).context("failed to encode PR feedback JSON")?; fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn write_pr_feedback_review_plan( workspace_root: &Path, snapshot: &PrFeedbackSnapshot, markdown_snapshot_path: &Path, json_snapshot_path: &Path, ) -> Result<PathBuf> { write_pr_feedback_review_plan_at( &pr_feedback_plan_root(), workspace_root, snapshot, markdown_snapshot_path, json_snapshot_path, ) } fn write_pr_feedback_kit_system_prompt( snapshot: &PrFeedbackSnapshot, markdown_snapshot_path: &Path, json_snapshot_path: &Path, review_plan_path: &Path, ) -> Result<PathBuf> { write_pr_feedback_kit_system_prompt_at( &pr_feedback_plan_root(), snapshot, markdown_snapshot_path, json_snapshot_path, review_plan_path, ) } fn write_pr_feedback_review_rules( workspace_root: &Path, snapshot: &PrFeedbackSnapshot, markdown_snapshot_path: &Path, json_snapshot_path: &Path, review_plan_path: &Path, kit_system_path: &Path, ) -> Result<PathBuf> { write_pr_feedback_review_rules_at( &pr_feedback_plan_root(), workspace_root, snapshot, markdown_snapshot_path, json_snapshot_path, review_plan_path, kit_system_path, ) } fn write_pr_feedback_review_rules_at( plan_root: &Path, workspace_root: &Path, snapshot: &PrFeedbackSnapshot, markdown_snapshot_path: &Path, json_snapshot_path: &Path, review_plan_path: &Path, kit_system_path: &Path, ) -> Result<PathBuf> { let path = pr_feedback_review_rules_path_at(plan_root, &snapshot.repo, snapshot.pr_number); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let canonical_rules_path = canonical_review_rules_doc_path(); let mut out = String::new(); out.push_str("# ["); out.push_str(&snapshot.pr_title); out.push_str("]("); out.push_str(&snapshot.pr_url); out.push_str(") Review Rules\n\n"); out.push_str("Generated operator artifact for resolving PR feedback item by item in the current workspace.\n\n"); out.push_str("- Workspace: `"); out.push_str(&workspace_root.display().to_string()); out.push_str("`\n"); out.push_str("- Feedback plan: `"); out.push_str(&review_plan_path.display().to_string()); out.push_str("`\n"); out.push_str("- Feedback snapshot (markdown): `"); out.push_str(&markdown_snapshot_path.display().to_string()); out.push_str("`\n"); out.push_str("- Feedback snapshot (json): `"); out.push_str(&json_snapshot_path.display().to_string()); out.push_str("`\n"); out.push_str("- Kit system prompt: `"); out.push_str(&kit_system_path.display().to_string()); out.push_str("`\n"); out.push_str("- Canonical shared rules: `"); out.push_str(&canonical_rules_path.display().to_string()); out.push_str("`\n\n"); out.push_str("## Run Here\n\n"); out.push_str("From the product workspace:\n\n```bash\n"); out.push_str("cd "); out.push_str(&workspace_root.display().to_string()); out.push_str("\nL check "); out.push_str(&snapshot.pr_url); out.push_str( "\nkit review --dir . --base origin/main --feedback-auto --preset designer\n```\n\n", ); out.push_str("Keep these visible together in Cursor:\n"); out.push_str("- current file under review\n"); out.push_str("- local diff for that file\n"); out.push_str("- this review-rules artifact\n"); out.push_str("- the feedback plan markdown\n"); out.push_str("- the feedback JSON snapshot\n"); out.push_str("- deterministic `kit review` output when relevant\n\n"); out.push_str("## One-Item Loop\n\n"); out.push_str("For every review item:\n\n"); out.push_str("1. read the reviewer comment\n"); out.push_str("2. inspect the exact diff hunk\n"); out.push_str("3. inspect adjacent code and dependent call sites\n"); out.push_str("4. decide the concern status in the current code: still applies here, moved nearby, already resolved, or not a real issue\n"); out.push_str("5. explain why the original diff ended up in that shape\n"); out.push_str("6. choose the smallest acceptable fix or explicit no-fix decision\n"); out.push_str("7. run the exact validation in the product repo\n"); out.push_str("8. capture one durable Kit-side lesson only if it is reusable\n\n"); out.push_str("## Prompt Template\n\n```text\n"); out.push_str("Use this PR review item block as the source of truth.\n\n"); out.push_str("Canonical review rules: "); out.push_str(&canonical_rules_path.display().to_string()); out.push_str("\nPR-local workflow artifact: "); out.push_str(&path.display().to_string()); out.push_str("\n\nWorkflow:\n"); out.push_str("- work in the product repo first\n"); out.push_str("- inspect the exact local diff and adjacent call sites before deciding\n"); out.push_str("- keep the product-repo fix separate from the future Kit improvement\n"); out.push_str("- if the durable lesson is about the review workflow or prompt contract, update the canonical review rules doc instead of AGENTS.md\n\n"); out.push_str("Task:\n"); out.push_str("1. Decide the Concern Status first: still applies here, moved nearby, already resolved, or not a real issue.\n"); out.push_str("2. Decide whether the reviewer is right in the current code shape.\n"); out.push_str("3. Explain why the current diff likely ended up in its flawed shape.\n"); out.push_str("4. If the concern still applies here or moved nearby, propose the smallest acceptable fix in the current branch. Otherwise explain why no patch is required.\n"); out.push_str("5. State the exact validation to run in the product repo.\n"); out.push_str("6. If the same diff exposes an adjacent issue, label it fix-now, defer, or ignore.\n"); out.push_str("7. If there is a durable lesson about the review operator workflow or prompt contract, propose the exact update for "); out.push_str(&canonical_rules_path.display().to_string()); out.push_str(".\n"); out.push_str("8. If there is a durable lesson about Kit review behavior, propose the exact AGENTS.md update for ~/repos/mark3labs/kit/AGENTS.md.\n"); out.push_str("9. If docs or AGENTS.md guidance are not enough, say what specific deterministic review rule or review-extension surface in ~/repos/mark3labs/kit should change.\n"); out.push_str("10. Keep scope tight and avoid broad refactors.\n\n"); out.push_str("Rules:\n"); out.push_str("- Decide Concern Status before proposing a patch.\n"); out.push_str("- Inspect the exact local diff and adjacent call sites before deciding.\n"); out.push_str("- Explain the coding habit, wrong assumption, or time pressure that likely produced the flawed diff shape.\n"); out.push_str("- Prefer the smallest fix that answers the reviewer directly.\n"); out.push_str("- If Concern Status is already resolved or not a real issue, do not invent a patch; explain the no-fix decision clearly.\n"); out.push_str("- Validation must name the actual command, test, or manual behavior to check.\n"); out.push_str("- Only propose a Kit upgrade if it is reusable across future PRs.\n"); out.push_str("- If the issue is product-specific and not reusable, say so explicitly.\n"); out.push_str("- Keep review-rules.md updates separate from AGENTS.md updates and separate from deterministic rule proposals.\n"); out.push_str("- Use the existing Concern Status / Local Verdict / Why This Happened / Narrow Fix / Validation / Prevention Candidate / Kit Upgrade notes in the block as priors if they are already good. Improve them only when needed.\n"); out.push_str("- Treat the item as complete only when Concern Status is explicit, validation has passed, the local review notes or ledger text is written, and the Prevention Candidate plus Kit Upgrade decision are explicitly recorded. A code patch is required only for Concern Status `still applies here` or `moved nearby`.\n\n"); out.push_str("Return sections:\n"); out.push_str("- Concern Status\n"); out.push_str("- Verdict\n"); out.push_str("- Why This Happened\n"); out.push_str("- Smallest Fix\n"); out.push_str("- Validation\n"); out.push_str("- Adjacent Coach Findings\n"); out.push_str("- Prevention Candidate\n"); out.push_str("- Kit Upgrade\n"); out.push_str("- Ledger Update\n"); out.push_str("- Completion Check\n"); out.push_str("```\n\n"); out.push_str("## Required Output Sections\n\n"); out.push_str("- `Concern Status`\n"); out.push_str("- `Verdict`\n"); out.push_str("- `Why This Happened`\n"); out.push_str("- `Smallest Fix`\n"); out.push_str("- `Validation`\n"); out.push_str("- `Adjacent Coach Findings`\n"); out.push_str("- `Prevention Candidate`\n"); out.push_str("- `Kit Upgrade`\n"); out.push_str("- `Ledger Update`\n"); out.push_str("- `Completion Check`\n\n"); out.push_str("## Kit Upgrade Decision Order\n\n"); out.push_str("1. `review-rules.md` update in `"); out.push_str(&canonical_rules_path.display().to_string()); out.push_str("`\n"); out.push_str("2. `AGENTS.md` update in `~/repos/mark3labs/kit/AGENTS.md`\n"); out.push_str("3. deterministic Kit review rule\n"); out.push_str("4. review extension / richer review packet\n"); out.push_str("5. no Kit change\n\n"); out.push_str("## Suggested Failure Modes\n\n"); out.push_str("- `wrong-fix-root-cause`\n"); out.push_str("- `unclear-extraction-intent`\n"); out.push_str("- `over-generic-abstraction`\n"); out.push_str("- `behavior-regression-risk`\n"); out.push_str("- `code-shape-needs-refactor`\n"); fs::write(&path, out).with_context(|| format!("failed to write {}", path.display()))?; Ok(path) } fn write_pr_feedback_kit_system_prompt_at( plan_root: &Path, snapshot: &PrFeedbackSnapshot, markdown_snapshot_path: &Path, json_snapshot_path: &Path, review_plan_path: &Path, ) -> Result<PathBuf> { let path = pr_feedback_kit_system_path_at(plan_root, &snapshot.repo, snapshot.pr_number); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut out = String::new(); out.push_str("# Kit PR Feedback Prevention System Prompt\n\n"); out.push_str("You are a strict repository review engineer. Study the attached PR feedback artifacts and design concrete guardrails so the same issues are caught before review next time.\n\n"); out.push_str("Priorities:\n"); out.push_str("1. Prefer deterministic prevention first: lint rules, diff rules, static checks, tests, review presets, or build-time assertions.\n"); out.push_str("2. Only propose agentic or extension-based review hooks when deterministic checks are insufficient.\n"); out.push_str("3. Tie every recommendation to exact files, commands, hook points, or review entrypoints.\n"); out.push_str("4. Do not restate reviewer comments. Explain root cause, prevention, and rollout cost.\n\n"); out.push_str("Attached artifacts:\n"); out.push_str("- Feedback snapshot markdown: `"); out.push_str(&markdown_snapshot_path.display().to_string()); out.push_str("`\n"); out.push_str("- Feedback snapshot json: `"); out.push_str(&json_snapshot_path.display().to_string()); out.push_str("`\n"); out.push_str("- Human review plan: `"); out.push_str(&review_plan_path.display().to_string()); out.push_str("`\n\n"); out.push_str("Expected output:\n"); out.push_str("## Root Causes\n"); out.push_str("- Group the feedback into a few structural failure modes.\n\n"); out.push_str("## Preventative Checks\n"); out.push_str("- For each failure mode, propose the smallest deterministic check that would have caught it.\n"); out.push_str("- Name the likely implementation target for each check.\n\n"); out.push_str("## Kit Review Bot Hooks\n"); out.push_str("- Only include hooks that add real signal beyond deterministic checks.\n"); out.push_str("- Describe the exact extension or review entrypoint to use.\n\n"); out.push_str("## Rollout\n"); out.push_str("- Order work from highest-signal/lowest-cost to lower-priority improvements.\n"); out.push_str("- Include validation steps for each added guardrail.\n"); fs::write(&path, out).with_context(|| format!("failed to write {}", path.display()))?; Ok(path) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PrFeedbackSeedKind { EnvContract, OverGeneric, OwnershipIntent, Default, } fn normalize_pr_feedback_seed_text(value: &str) -> String { value.split_whitespace().collect::<Vec<_>>().join(" ") } fn truncate_pr_feedback_seed_text(value: &str, max_chars: usize) -> String { let normalized = normalize_pr_feedback_seed_text(value); let mut chars = normalized.chars(); let truncated = chars.by_ref().take(max_chars).collect::<String>(); if chars.next().is_some() { format!("{truncated}...") } else { truncated } } fn pr_feedback_seed_kind(item: &PrFeedbackItem) -> PrFeedbackSeedKind { let body = item.body.to_ascii_lowercase(); if body.contains(".env") || body.contains("env file") || body.contains("environment") { PrFeedbackSeedKind::EnvContract } else if body.contains("generic") { PrFeedbackSeedKind::OverGeneric } else if body.contains("intent") || body.contains("intnet") || body.contains("moving") || body.contains("move this") { PrFeedbackSeedKind::OwnershipIntent } else { PrFeedbackSeedKind::Default } } fn pr_feedback_file_label(item: &PrFeedbackItem) -> String { let file = item .path .as_deref() .and_then(|value| Path::new(value).file_name()) .and_then(|value| value.to_str()) .unwrap_or("current file"); match item.line { Some(line) => format!("{file}:{line}"), None => file.to_string(), } } fn pr_feedback_area_label(item: &PrFeedbackItem) -> &'static str { match item.path.as_deref() { Some(path) if path.starts_with("ide/designer") => "Designer", _ => "the product", } } fn seeded_pr_feedback_plan_sections( item: &PrFeedbackItem, ) -> (String, String, String, String, String, String, String) { let focus = truncate_pr_feedback_seed_text(&item.body, 160); let file_label = pr_feedback_file_label(item); let area = pr_feedback_area_label(item); let concern_status = "decide whether it still applies here, moved nearby, is already resolved, or is not a real issue".to_string(); match pr_feedback_seed_kind(item) { PrFeedbackSeedKind::EnvContract => ( concern_status, "Reviewer is likely right that this should be fixed in configuration rather than hidden behind a runtime guard.".to_string(), "The diff likely added a defensive env check because local setup felt fragile, but that weakens a required runtime contract instead of enforcing it.".to_string(), format!( "Remove the runtime configuration band-aid in {file_label} and keep the required env contract explicit at the real usage boundary." ), format!( "Run the smallest {area} check with the env configured correctly and confirm the feature still works without the extra guard." ), "Review rule: do not add runtime env fallbacks when the feature contract requires setup-time configuration.".to_string(), "review-rules.md if this survives validation; otherwise none.".to_string(), ), PrFeedbackSeedKind::OverGeneric => ( concern_status, "Reviewer is likely right to question whether this abstraction is more generic than the current product need.".to_string(), "The diff likely generalized early to feel reusable, but the reviewer only sees one concrete use case so the abstraction now overpromises intent.".to_string(), format!( "Narrow the abstraction in {file_label} to the concrete use case unless at least two real call sites justify keeping it generic." ), format!( "Exercise the exact {area} flow that needs this code and confirm the narrower API still covers the behavior without breaking adjacent callers." ), "Review rule: do not introduce generic helpers or executors unless the diff shows at least two real consumers or clearly justifies the shared contract.".to_string(), "deterministic Kit review rule if this pattern is detectable; otherwise review-rules.md.".to_string(), ), PrFeedbackSeedKind::OwnershipIntent => ( concern_status, "The reviewer is asking for intent and ownership, not just whether the code compiles.".to_string(), "The diff likely moved or extracted code to clean up structure, but it did not make the new ownership boundary legible to a reviewer.".to_string(), format!( "Make the smallest placement or naming change in {file_label} that makes the owner and intent obvious at the touched call site." ), format!( "Open the affected {area} flow and confirm the behavior is unchanged while the new ownership boundary is easier to explain." ), "Review rule: when moving logic across component or module boundaries, make the new owner explicit in names or keep the logic colocated.".to_string(), "review-rules.md unless this turns into a deterministic ownership-boundary rule.".to_string(), ), PrFeedbackSeedKind::Default => ( concern_status, format!("Judge whether the reviewer is right about: {focus}"), "The diff likely optimized for implementation speed before making the intent legible to a reviewer.".to_string(), format!( "Make the smallest change in {file_label} and its immediate call site that answers the reviewer directly." ), format!( "Run the smallest relevant {area} check for this flow and confirm adjacent behavior did not regress." ), "Review heuristic: when a diff introduces a new helper, move, or behavior change, the surrounding call sites should make the intent obvious.".to_string(), "review-rules.md if the prevention survives validation; otherwise none.".to_string(), ), } } fn write_pr_feedback_review_plan_at( plan_root: &Path, workspace_root: &Path, snapshot: &PrFeedbackSnapshot, markdown_snapshot_path: &Path, json_snapshot_path: &Path, ) -> Result<PathBuf> { let path = pr_feedback_review_plan_path_at(plan_root, &snapshot.repo, snapshot.pr_number); let review_rules_path = pr_feedback_review_rules_path_at(plan_root, &snapshot.repo, snapshot.pr_number); let kit_system_path = pr_feedback_kit_system_path_at(plan_root, &snapshot.repo, snapshot.pr_number); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut out = String::new(); out.push_str("# ["); out.push_str(&snapshot.pr_title); out.push_str("]("); out.push_str(&snapshot.pr_url); out.push_str(")\n\n"); out.push_str("- Repo: `"); out.push_str(&snapshot.repo); out.push_str("`\n"); out.push_str("- PR: #"); out.push_str(&snapshot.pr_number.to_string()); out.push('\n'); out.push_str("- URL: "); out.push_str(&snapshot.pr_url); out.push('\n'); out.push_str("- Trace ID: `"); out.push_str(&snapshot.trace_id); out.push_str("`\n"); out.push_str("- Workspace: `"); out.push_str(&workspace_root.display().to_string()); out.push_str("`\n"); out.push_str("- Generated: "); out.push_str(&snapshot.generated_at); out.push('\n'); out.push_str("- Snapshot (markdown): `"); out.push_str(&markdown_snapshot_path.display().to_string()); out.push_str("`\n"); out.push_str("- Snapshot (json): `"); out.push_str(&json_snapshot_path.display().to_string()); out.push_str("`\n"); out.push_str("- Review rules: `"); out.push_str(&review_rules_path.display().to_string()); out.push_str("`\n\n"); out.push_str("## Cursor Review\n\n"); out.push_str("Reopen the workspace and review artifacts together:\n\n```bash\n"); out.push_str(&cursor_review_open_command(&snapshot.pr_url, true, true)); out.push_str("\n```\n\n"); out.push_str("Keep these visible while resolving comments:\n"); out.push_str("- the repo checkout / JJ workspace\n"); out.push_str("- the generated review-rules artifact\n"); out.push_str("- this feedback plan\n"); out.push_str("- the current file diff\n"); out.push_str("- the Kit system prompt for later prevention work\n\n"); out.push_str("## Kit Commands\n\n"); out.push_str("Deterministic review gate:\n\n```bash\n"); out.push_str( "kit review --dir . --base origin/main --feedback-auto --preset designer --json\n", ); out.push_str("```\n\n"); out.push_str("Preventative rule synthesis from this feedback set:\n\n```bash\n"); out.push_str("kit --system-prompt "); out.push_str(&kit_system_path.display().to_string()); out.push_str(" @"); out.push_str(&json_snapshot_path.display().to_string()); out.push_str(" @"); out.push_str(&path.display().to_string()); out.push_str(" \"Design preventative review and lint rules for this feedback set.\" --json\n"); out.push_str("```\n\n"); let unique_files: Vec<String> = { let mut seen = HashSet::new(); snapshot .items .iter() .filter_map(|item| item.path.as_ref()) .filter(|path| seen.insert((*path).clone())) .take(8) .cloned() .collect() }; out.push_str("## Summary\n\n"); out.push_str("- Actionable items: "); out.push_str(&snapshot.items.len().to_string()); out.push('\n'); if !unique_files.is_empty() { out.push_str("- Main files: "); out.push_str( &unique_files .iter() .map(|path| format!("`{path}`")) .collect::<Vec<_>>() .join(", "), ); out.push('\n'); } out.push_str("- Review states: "); let mut review_states: Vec<(String, usize)> = snapshot .review_state_counts .iter() .map(|(state, count)| (state.clone(), *count)) .collect(); review_states.sort_by(|a, b| a.0.cmp(&b.0)); if review_states.is_empty() { out.push_str("none"); } else { out.push_str( &review_states .into_iter() .map(|(state, count)| format!("{state}:{count}")) .collect::<Vec<_>>() .join(", "), ); } out.push_str("\n\n"); for (idx, item) in snapshot.items.iter().enumerate() { out.push_str("## Item "); out.push_str(&(idx + 1).to_string()); out.push_str(": "); out.push_str( &feedback_location_label(item) .or_else(|| item.path.clone()) .unwrap_or_else(|| format!("{} feedback", item.source)), ); out.push_str("\n\n"); out.push_str("### Reviewer Feedback\n\n"); out.push_str("- Source: `"); out.push_str(item.source); out.push_str("`\n"); out.push_str("- Author: `"); out.push_str(&item.author); out.push_str("`\n"); if let Some(state) = feedback_review_state_label(item) { out.push_str("- Review state: `"); out.push_str(&state); out.push_str("`\n"); } if let Some(location) = feedback_location_label(item) { out.push_str("- Location: `"); out.push_str(&location); out.push_str("`\n"); } out.push_str("- URL: "); out.push_str(&item.url); out.push_str("\n\n"); out.push_str(item.body.trim()); out.push_str("\n\n"); if let Some(diff_hunk) = item .diff_hunk .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { out.push_str("### Diff Hunk\n\n```diff\n"); out.push_str(&compact_diff_hunk(diff_hunk, 24, 1200)); out.push_str("\n```\n\n"); } let (concern_status, verdict, why, fix, validation, prevention, kit_upgrade) = seeded_pr_feedback_plan_sections(item); out.push_str("### Concern Status\n\n"); out.push_str("- "); out.push_str(&concern_status); out.push_str("\n\n"); out.push_str("### Local Verdict\n\n"); out.push_str("- "); out.push_str(&verdict); out.push_str("\n\n"); out.push_str("### Why This Happened\n\n"); out.push_str("- "); out.push_str(&why); out.push_str("\n\n"); out.push_str("### Narrow Fix\n\n"); out.push_str("- "); out.push_str(&fix); out.push_str("\n\n"); out.push_str("### Validation\n\n"); out.push_str("- "); out.push_str(&validation); out.push_str("\n\n"); out.push_str("### Prevention Candidate\n\n"); out.push_str("- "); out.push_str(&prevention); out.push_str("\n\n"); out.push_str("### Kit Upgrade\n\n"); out.push_str("- "); out.push_str(&kit_upgrade); out.push_str("\n\n"); out.push_str("### Status\n\n"); out.push_str("- [ ] open\n"); out.push_str("- [ ] patched\n"); out.push_str("- [ ] validated\n"); out.push_str("- [ ] prevention-captured\n\n"); } out.push_str("## Kit Input\n\n```json\n"); out.push_str( &serde_json::to_string_pretty(snapshot).context("failed to render kit input JSON")?, ); out.push_str("\n```\n"); fs::write(&path, out).with_context(|| format!("failed to write {}", path.display()))?; Ok(path) } fn load_pr_feedback_data(repo_root: &Path, selector: Option<&str>) -> Result<LoadedPrFeedback> { let (repo, pr_number, pr_url) = if let Some(selector) = selector { if let Some((repo, pr_number)) = parse_github_pr_url(selector) { let pr_url = format!("https://github.com/{repo}/pull/{pr_number}"); (repo, pr_number, pr_url) } else { let trimmed = selector.trim().trim_start_matches('#'); let pr_number = trimmed.parse::<u64>().with_context(|| { format!("invalid PR selector `{selector}`; expected number or URL") })?; let repo = resolve_github_repo(repo_root)?; let pr_url = format!("https://github.com/{repo}/pull/{pr_number}"); (repo, pr_number, pr_url) } } else { let repo = resolve_github_repo(repo_root)?; let (pr_number, pr_url) = resolve_current_pr_for_feedback(repo_root, &repo).with_context( || "failed to resolve current PR. Pass an explicit selector: `f pr feedback <number>`", )?; (repo, pr_number, pr_url) }; let reviews_endpoint = format!("repos/{repo}/pulls/{pr_number}/reviews?per_page=100"); let review_comments_endpoint = format!("repos/{repo}/pulls/{pr_number}/comments?per_page=100"); let issue_comments_endpoint = format!("repos/{repo}/issues/{pr_number}/comments?per_page=100"); let pr_title = gh_capture_in( repo_root, &[ "pr", "view", &pr_number.to_string(), "--repo", &repo, "--json", "title", ], ) .and_then(|out| { serde_json::from_str::<GhPrTitleSummary>(out.trim()) .map(|parsed| parsed.title) .context("failed to parse gh pr view title JSON") }) .unwrap_or_else(|_| format!("PR #{}", pr_number)); let reviews: Vec<GhReview> = gh_api_json_in(repo_root, &reviews_endpoint)?; let review_comments: Vec<GhPrReviewComment> = gh_api_json_in(repo_root, &review_comments_endpoint)?; let issue_comments: Vec<GhIssueComment> = gh_api_json_in(repo_root, &issue_comments_endpoint)?; let review_thread_ids = gh_review_thread_ids_by_comment_url(repo_root, &repo, pr_number).unwrap_or_default(); let mut items: Vec<PrFeedbackItem> = Vec::new(); for comment in &review_comments { if comment.in_reply_to_id.is_some() { continue; } let body = comment.body.trim(); if body.is_empty() { continue; } items.push(PrFeedbackItem { external_ref: pr_feedback_external_ref(&repo, pr_number, "review-comment", comment.id), source: "review-comment", author: comment.user.login.clone(), body: body.to_string(), url: comment.html_url.trim().to_string(), thread_id: review_thread_ids.get(comment.html_url.trim()).cloned(), path: comment.path.clone(), line: comment.line, review_state: None, diff_hunk: comment.diff_hunk.clone(), }); } for comment in &issue_comments { let body = comment.body.trim(); if body.is_empty() { continue; } items.push(PrFeedbackItem { external_ref: pr_feedback_external_ref(&repo, pr_number, "issue-comment", comment.id), source: "issue-comment", author: comment.user.login.clone(), body: body.to_string(), url: comment.html_url.trim().to_string(), thread_id: None, path: None, line: None, review_state: None, diff_hunk: None, }); } for review in &reviews { let body = review.body.trim(); if body.is_empty() { continue; } items.push(PrFeedbackItem { external_ref: pr_feedback_external_ref(&repo, pr_number, "review", review.id), source: "review", author: review.user.login.clone(), body: body.to_string(), url: review.html_url.trim().to_string(), thread_id: None, path: None, line: None, review_state: Some(review.state.trim().to_string()), diff_hunk: None, }); } Ok(LoadedPrFeedback { repo, pr_number, pr_url, pr_title, reviews, review_comments, issue_comments, items, }) } fn build_pr_feedback_snapshot(data: &LoadedPrFeedback) -> PrFeedbackSnapshot { PrFeedbackSnapshot { repo: data.repo.clone(), pr_number: data.pr_number, pr_url: data.pr_url.clone(), pr_title: data.pr_title.clone(), trace_id: new_pr_feedback_trace_id(), generated_at: chrono::Utc::now().to_rfc3339(), reviews_count: data.reviews.len(), review_comments_count: data.review_comments.len(), issue_comments_count: data.issue_comments.len(), review_state_counts: review_state_counts_map(&data.reviews), items: data.items.clone(), } } fn write_pr_feedback_artifacts( repo_root: &Path, data: &LoadedPrFeedback, ) -> Result<(PrFeedbackSnapshot, PrFeedbackArtifacts)> { let snapshot = build_pr_feedback_snapshot(data); let snapshot_path = write_pr_feedback_snapshot( repo_root, &data.repo, data.pr_number, &data.pr_url, &snapshot.trace_id, &data.items, )?; let snapshot_json_path = pr_feedback_snapshot_json_path(repo_root, data.pr_number)?; write_pr_feedback_snapshot_json(&snapshot, &snapshot_json_path)?; let review_plan_path = write_pr_feedback_review_plan(repo_root, &snapshot, &snapshot_path, &snapshot_json_path)?; let kit_system_path = write_pr_feedback_kit_system_prompt( &snapshot, &snapshot_path, &snapshot_json_path, &review_plan_path, )?; let review_rules_path = write_pr_feedback_review_rules( repo_root, &snapshot, &snapshot_path, &snapshot_json_path, &review_plan_path, &kit_system_path, )?; Ok(( snapshot, PrFeedbackArtifacts { snapshot_path, snapshot_json_path, review_plan_path, review_rules_path, kit_system_path, }, )) } fn render_pr_feedback_reference( workspace_root: &Path, snapshot: &PrFeedbackSnapshot, artifacts: &PrFeedbackArtifacts, ) -> String { let mut out = String::new(); out.push_str("[pr-feedback]\n"); out.push_str("Workspace: "); out.push_str(&workspace_root.display().to_string()); out.push('\n'); out.push_str("PR feedback: "); out.push_str(&snapshot.repo); out.push('#'); out.push_str(&snapshot.pr_number.to_string()); out.push('\n'); out.push_str("Trace ID: "); out.push_str(&snapshot.trace_id); out.push('\n'); out.push_str("URL: "); out.push_str(&snapshot.pr_url); out.push('\n'); out.push_str("Snapshot markdown: "); out.push_str(&artifacts.snapshot_path.display().to_string()); out.push('\n'); out.push_str("Snapshot json: "); out.push_str(&artifacts.snapshot_json_path.display().to_string()); out.push('\n'); out.push_str("Review plan: "); out.push_str(&artifacts.review_plan_path.display().to_string()); out.push('\n'); out.push_str("Review rules: "); out.push_str(&artifacts.review_rules_path.display().to_string()); out.push('\n'); out.push_str("Kit system prompt: "); out.push_str(&artifacts.kit_system_path.display().to_string()); out.push('\n'); out.push_str("Cursor reopen: "); out.push_str(&cursor_review_open_command(&snapshot.pr_url, true, true)); out.push_str("\n\n"); out.push_str("Summary:\n"); out.push_str("- Actionable items: "); out.push_str(&snapshot.items.len().to_string()); out.push('\n'); let mut review_states: Vec<(String, usize)> = snapshot .review_state_counts .iter() .map(|(state, count)| (state.clone(), *count)) .collect(); review_states.sort_by(|a, b| a.0.cmp(&b.0)); out.push_str("- Review states: "); if review_states.is_empty() { out.push_str("none"); } else { out.push_str( &review_states .into_iter() .map(|(state, count)| format!("{state}:{count}")) .collect::<Vec<_>>() .join(", "), ); } out.push_str("\n\nTop feedback items:\n"); for (idx, item) in snapshot.items.iter().take(6).enumerate() { out.push_str(&(idx + 1).to_string()); out.push_str(". "); if let Some(location) = feedback_location_label(item) { out.push_str(&location); out.push_str(" - "); } out.push_str(&compact_single_line(&item.body, 140)); out.push('\n'); if let Some(diff_hunk) = item .diff_hunk .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { out.push_str(" diff:\n"); for line in compact_diff_hunk(diff_hunk, 8, 500).lines() { out.push_str(" "); out.push_str(line); out.push('\n'); } } } out.push_str("\nPlan excerpt:\n"); if let Ok(body) = fs::read_to_string(&artifacts.review_plan_path) { out.push_str(&compact_pr_feedback_context_block(&body, 40, 2400)); } else { out.push_str("- Unable to read generated review plan."); } out } pub fn resolve_pr_feedback_reference(repo_root: &Path, selector: &str) -> Result<String> { ensure_gh_available()?; let data = load_pr_feedback_data(repo_root, Some(selector))?; let (snapshot, artifacts) = write_pr_feedback_artifacts(repo_root, &data)?; Ok(render_pr_feedback_reference( repo_root, &snapshot, &artifacts, )) } fn run_pr_feedback(repo_root: &Path, cmd: PrFeedbackCommand) -> Result<()> { ensure_gh_available()?; if let Some(selector) = cmd.selector.as_deref() { if selector == "--help" || selector == "-h" { println!("Usage: f pr feedback [<pr-number|pr-url>] [--todo] [--compact] [--cursor]"); println!("Examples:"); println!(" f pr feedback"); println!(" f pr feedback 8"); println!(" f pr feedback https://github.com/owner/repo/pull/8 --todo"); println!(" f pr feedback 8"); println!(" f pr feedback 8 --compact"); println!(" f pr feedback 8 --compact --cursor"); return Ok(()); } } let data = load_pr_feedback_data(repo_root, cmd.selector.as_deref())?; let repo = data.repo.clone(); let pr_number = data.pr_number; let pr_url = data.pr_url.clone(); let reviews = &data.reviews; let review_comments = &data.review_comments; let issue_comments = &data.issue_comments; let items = &data.items; let (snapshot, artifacts) = write_pr_feedback_artifacts(repo_root, &data)?; println!("PR feedback: {repo}#{pr_number}"); println!("Trace ID: {}", snapshot.trace_id); println!("URL: {pr_url}"); println!( "Reviews: {} ({})", reviews.len(), format_review_state_counts(&reviews) ); println!("Review comments: {}", review_comments.len()); println!("Issue comments: {}", issue_comments.len()); println!("Snapshot: {}", artifacts.snapshot_path.display()); println!("Snapshot JSON: {}", artifacts.snapshot_json_path.display()); println!("Review plan: {}", artifacts.review_plan_path.display()); println!("Review rules: {}", artifacts.review_rules_path.display()); println!("Kit system prompt: {}", artifacts.kit_system_path.display()); println!( "Cursor reopen: {}", cursor_review_open_command(&pr_url, true, true) ); if cmd.open_cursor { open_cursor_review_bundle( repo_root, &artifacts.review_plan_path, &artifacts.review_rules_path, &artifacts.kit_system_path, true, )?; println!("Cursor: opened workspace + review artifacts"); } if items.is_empty() { println!("No actionable text feedback found."); return Ok(()); } println!(); println!("Actionable items ({}):", items.len()); for (idx, item) in items.iter().enumerate() { let preview = compact_single_line(&item.body, 120); if let Some(location) = feedback_location_label(item) { println!("{}. [{}] {} {}", idx + 1, item.source, location, preview); } else { println!("{}. [{}] {}", idx + 1, item.source, preview); } println!(" by {} {}", item.author, item.url); if cmd.show_full { if let Some(state) = feedback_review_state_label(item) { println!(" state: {}", state); } if let Some(diff_hunk) = item .diff_hunk .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { println!(" diff:"); for line in compact_diff_hunk(diff_hunk, 16, 900).lines() { println!(" {}", line); } } } } if cmd.record_todos { let created = record_pr_feedback_todos(repo_root, &repo, pr_number, &items)?; if created.is_empty() { println!("Todos: no new todos created (all feedback already tracked)."); } else { println!("Todos: created {} item(s).", created.len()); println!("Use `f todo list` to review them."); } } else { println!("Tip: rerun with `--todo` to record these items into `.ai/todos/todos.json`."); } Ok(()) } #[derive(Debug, Clone)] struct GhPrView { title: String, body: String, } fn gh_pr_view(repo_root: &Path, repo: &str, number: u64) -> Result<GhPrView> { #[derive(serde::Deserialize)] struct GhPrJson { title: String, body: String, } let out = gh_capture_in( repo_root, &[ "pr", "view", &number.to_string(), "--repo", repo, "--json", "title,body", ], )?; let parsed: GhPrJson = serde_json::from_str(out.trim()) .with_context(|| format!("failed to parse gh pr view JSON for #{number}"))?; Ok(GhPrView { title: parsed.title, body: parsed.body, }) } fn flow_project_name(repo_root: &Path) -> String { let flow_toml = repo_root.join("flow.toml"); if flow_toml.exists() { if let Ok(cfg) = crate::config::load(&flow_toml) { if let Some(name) = cfg .project_name .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { return name.to_string(); } } } repo_root .file_name() .and_then(|n| n.to_str()) .unwrap_or("project") .to_string() } fn open_in_zed_preview(path: &Path) -> Result<()> { // Prefer Zed Preview if installed, otherwise fall back to Zed. let try_open = |app: &str| -> Result<()> { Command::new("open") .args(["-a", app]) .arg(path) .status() .with_context(|| format!("failed to open {app}"))?; Ok(()) }; try_open("/Applications/Zed Preview.app").or_else(|_| try_open("/Applications/Zed.app")) } fn parse_pr_edit_markdown(text: &str) -> Result<(String, String)> { // Expected shape: // # Title // <one line title> // // # Description // <markdown body...> let mut title: Option<String> = None; let mut desc_lines: Vec<String> = Vec::new(); let mut lines = text.lines().peekable(); while let Some(line) = lines.next() { let l = line.trim_end(); if l.trim() == "# Title" { // Consume subsequent blank lines then read the first non-empty line as the title. while let Some(nl) = lines.peek() { if nl.trim().is_empty() { lines.next(); } else { break; } } if let Some(nl) = lines.peek() { let t = nl.trim(); if !t.is_empty() { title = Some(t.to_string()); } } continue; } if l.trim() == "# Description" { // Skip leading blank lines in description. while let Some(nl) = lines.peek() { if nl.trim().is_empty() { lines.next(); } else { break; } } // Collect remainder verbatim. for rest in lines { desc_lines.push(rest.to_string()); } break; } } let title = title.unwrap_or_default().trim().to_string(); if title.is_empty() { bail!("missing PR title in edit file (expected a non-empty line under `# Title`)"); } let body = desc_lines.join("\n").trim_end().to_string(); Ok((title, body)) } fn render_pr_edit_markdown(title: &str, body: &str) -> String { let mut out = String::new(); out.push_str("# Title\n\n"); out.push_str(title.trim()); out.push_str("\n\n# Description\n\n"); out.push_str(body.trim_end()); out.push('\n'); out } fn render_pr_edit_markdown_with_frontmatter( repo: &str, number: u64, title: &str, body: &str, ) -> String { let mut out = String::new(); out.push_str("---\n"); out.push_str("repo: "); out.push_str(repo.trim()); out.push('\n'); out.push_str("pr: "); out.push_str(&number.to_string()); out.push_str("\n---\n\n"); out.push_str(&render_pr_edit_markdown(title, body)); out } fn strip_existing_frontmatter(text: &str) -> &str { // If the file starts with a YAML frontmatter block, strip it so we can replace/insert ours. // Frontmatter: // --- // ... // --- let mut lines = text.lines(); let Some(first) = lines.next() else { return text; }; if first.trim() != "---" { return text; } let mut idx = first.len() + 1; // include newline for line in lines { idx += line.len() + 1; if line.trim() == "---" { break; } } // Skip trailing blank line(s) after frontmatter. let remainder = &text[idx..]; remainder.trim_start_matches('\n') } fn ensure_pr_edit_frontmatter(path: &Path, repo: &str, number: u64) -> Result<()> { use std::fs; let existing = fs::read_to_string(path).unwrap_or_default(); let remainder = strip_existing_frontmatter(&existing); let rendered = format!( "---\nrepo: {}\npr: {}\n---\n\n{}", repo.trim(), number, remainder.trim_start() ); if rendered != existing { fs::write(path, rendered)?; } Ok(()) } fn gh_pr_edit(repo_root: &Path, repo: &str, number: u64, title: &str, body: &str) -> Result<()> { use std::fs; let tmp_dir = std::env::temp_dir().join("flow-pr-edit"); let _ = fs::create_dir_all(&tmp_dir); let patch_path = tmp_dir.join(format!("pr-{number}.patch.json")); let normalized_body = normalize_markdown_linebreaks(body); let payload = serde_json::json!({ "title": title, "body": normalized_body, }); fs::write(&patch_path, serde_json::to_string(&payload)?)?; // Use the REST API instead of `gh pr edit` to avoid GitHub GraphQL breaking changes. let endpoint = format!("repos/{repo}/pulls/{number}"); let output = Command::new("gh") .current_dir(repo_root) .arg("api") .arg("-X") .arg("PATCH") .arg(endpoint) .arg("--input") .arg(&patch_path) .arg("--silent") .output() .context("failed to run gh api (PATCH pull request)")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); bail!("failed to update PR via GitHub API:\n{stdout}\n{stderr}"); } Ok(()) } fn resolve_pr_for_open(repo_root: &Path, opts: &PrOpts) -> Result<(String, u64, String)> { ensure_gh_available()?; let repo = resolve_github_repo(repo_root)?; // Prefer opening based on the current git branch name (most intuitive UX). let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); if !branch.is_empty() && branch != "HEAD" { if let Some((n, url)) = gh_find_open_pr_by_head(repo_root, &repo, &branch)? { return Ok((repo, n, url)); } } // Fallback: open based on queued commit (by explicit hash, by HEAD SHA, or latest entry). let hash = if let Some(hash) = opts.hash.clone() { hash } else { let head_sha = git_capture_in(repo_root, &["rev-parse", "HEAD"]) .unwrap_or_default() .trim() .to_string(); let _ = refresh_commit_queue(repo_root); let mut entries = load_commit_queue_entries(repo_root)?; if entries.is_empty() { bail!("No PR found for current branch and commit queue is empty."); } if !head_sha.is_empty() { if let Some(entry) = entries.iter().rev().find(|e| e.commit_sha == head_sha) { entry.commit_sha.clone() } else { entries.pop().unwrap().commit_sha } } else { entries.pop().unwrap().commit_sha } }; let mut entry = resolve_commit_queue_entry(repo_root, &hash)?; let _ = refresh_queue_entry_commit(repo_root, &mut entry); let head = default_pr_head(&entry); let (number, url) = if let Some(url) = entry .pr_url .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) { let n = entry .pr_number .or_else(|| pr_number_from_url(&url)) .unwrap_or(0); if n > 0 { (n, url) } else if let Some((n, u)) = gh_find_open_pr_by_head(repo_root, &repo, &head)? { (n, u) } else { // If URL exists but we can't parse number or find by head, re-create is risky; just fail. bail!("found PR url in queue entry but could not resolve PR number"); } } else if let Some((n, u)) = gh_find_open_pr_by_head(repo_root, &repo, &head)? { (n, u) } else { // Create it if missing (as draft). let gh_head = ensure_pr_head_pushed(repo_root, &head, &entry.commit_sha)?; let (title, body_rest) = commit_message_title_body(&entry.message); let (n, u) = if let Some(found) = gh_find_open_pr_by_head(repo_root, &repo, &gh_head)? { found } else { gh_create_pr( repo_root, &repo, &gh_head, &opts.base, &title, body_rest.trim(), true, )? }; entry.pr_number = Some(n); entry.pr_url = Some(u.clone()); entry.pr_head = Some(head.clone()); entry.pr_base = Some(opts.base.clone()); let _ = write_commit_queue_entry(repo_root, &entry); (n, u) }; Ok((repo, number, url)) } fn run_pr_open_edit(repo_root: &Path, opts: &PrOpts) -> Result<()> { use ::notify::RecursiveMode; use notify_debouncer_mini::new_debouncer; use std::fs; use std::sync::mpsc; use std::time::Duration; let (repo, number, url) = resolve_pr_for_open(repo_root, opts)?; let current = gh_pr_view(repo_root, &repo, number)?; let project = flow_project_name(repo_root); let home = dirs::home_dir().context("could not resolve home directory")?; let edit_dir = home.join(".flow").join("pr-edit"); fs::create_dir_all(&edit_dir)?; let edit_path = edit_dir.join(format!("{project}-{number}.md")); if !edit_path.exists() { let rendered = render_pr_edit_markdown_with_frontmatter(&repo, number, ¤t.title, ¤t.body); fs::write(&edit_path, rendered)?; } else { // Backfill frontmatter for older files so the always-on daemon can sync them. let _ = ensure_pr_edit_frontmatter(&edit_path, &repo, number); } // Register a sidecar mapping too (useful if users delete the frontmatter). let _ = crate::pr_edit::index_upsert_file(&edit_path, &repo, number); println!("PR: {url}"); if !opts.no_open { let _ = open_in_browser(&url); } open_in_zed_preview(&edit_path)?; println!( "Editing {} (save to sync to GitHub, Ctrl-C to stop)", edit_path.display() ); // Seed hash so the initial file creation/open doesn't immediately trigger an API update. let mut last_hash: Option<String> = fs::read_to_string(&edit_path).ok().map(|text| { let mut hasher = std::collections::hash_map::DefaultHasher::new(); use std::hash::Hash; use std::hash::Hasher; text.hash(&mut hasher); format!("{:x}", hasher.finish()) }); let (event_tx, event_rx) = mpsc::channel(); let mut debouncer = new_debouncer(Duration::from_millis(250), event_tx) .context("failed to initialize file watcher")?; debouncer .watcher() .watch( edit_path.parent().unwrap_or(repo_root).as_ref(), RecursiveMode::NonRecursive, ) .with_context(|| format!("failed to watch {}", edit_path.display()))?; loop { match event_rx.recv() { Ok(Ok(events)) => { let touched = events.iter().any(|e| e.path == edit_path); if !touched { continue; } let Ok(text) = fs::read_to_string(&edit_path) else { continue; }; // Lightweight dedupe to avoid re-sending on editor temp writes. let mut hasher = std::collections::hash_map::DefaultHasher::new(); use std::hash::Hash; use std::hash::Hasher; text.hash(&mut hasher); let h = format!("{:x}", hasher.finish()); if last_hash.as_deref() == Some(&h) { continue; } last_hash = Some(h); match parse_pr_edit_markdown(&text) { Ok((title, body)) => { if let Err(err) = gh_pr_edit(repo_root, &repo, number, &title, &body) { eprintln!("Failed to update PR #{number}: {err:#}"); } else { println!("✓ Updated PR #{number}"); } } Err(err) => { eprintln!("Skipped update: {err:#}"); } } } Ok(Err(err)) => { eprintln!("watcher error: {err:?}"); } Err(_) => break, } } Ok(()) } fn format_queue_created_at(ts: &str) -> String { if ts.trim().is_empty() { return "unknown".to_string(); } let parsed = chrono::DateTime::parse_from_rfc3339(ts).or_else(|_| { chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.fZ") .map(|dt| dt.and_utc().fixed_offset()) }); let Ok(dt) = parsed else { return ts.to_string(); }; let now = chrono::Utc::now(); let duration = now.signed_duration_since(dt); let seconds = duration.num_seconds(); if seconds < 0 { return "just now".to_string(); } let minutes = duration.num_minutes(); let hours = duration.num_hours(); let days = duration.num_days(); let weeks = days / 7; if seconds < 60 { "just now".to_string() } else if minutes < 60 { format!("{}m ago", minutes) } else if hours < 24 { format!("{}h ago", hours) } else if days == 1 { "yesterday".to_string() } else if days < 7 { format!("{}d ago", days) } else if weeks < 4 { format!("{}w ago", weeks) } else { dt.format("%b %d").to_string() } } fn get_openai_key() -> Result<String> { std::env::var("OPENAI_API_KEY").context("OPENAI_API_KEY environment variable not set") } #[derive(Debug, Clone)] enum CommitMessageProvider { OpenAi { api_key: String }, Remote { api_url: String, token: String }, } #[derive(Debug, Clone)] enum CommitMessageOverride { Selection(CommitMessageSelection), } fn parse_commit_message_override( tool: &str, model: Option<String>, ) -> Option<CommitMessageOverride> { parse_commit_message_selection_with_model(tool, model).map(CommitMessageOverride::Selection) } fn resolve_commit_message_override(repo_root: &Path) -> Option<CommitMessageOverride> { // TypeScript config has highest priority. if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(commit) = flow.commit { if let Some(tool) = commit.message_tool { return parse_commit_message_override(&tool, commit.message_model); } } } } // Local flow.toml let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(commit_cfg) = cfg.commit.as_ref() { if let Some(tool) = commit_cfg.message_tool.as_deref() { return parse_commit_message_override(tool, commit_cfg.message_model.clone()); } } } } // Global flow config let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { if let Some(commit_cfg) = cfg.commit.as_ref() { if let Some(tool) = commit_cfg.message_tool.as_deref() { return parse_commit_message_override(tool, commit_cfg.message_model.clone()); } } } } None } fn resolve_commit_message_providers() -> Vec<CommitMessageProvider> { let mut providers = Vec::new(); if let Ok(Some(token)) = crate::env::load_ai_auth_token() { if let Ok(api_url) = crate::env::load_ai_api_url() { let trimmed_url = api_url.trim().trim_end_matches('/').to_string(); if !trimmed_url.is_empty() { providers.push(CommitMessageProvider::Remote { api_url: trimmed_url, token, }); } } } if let Ok(api_key) = get_openai_key() { let trimmed = api_key.trim().to_string(); if !trimmed.is_empty() { providers.push(CommitMessageProvider::OpenAi { api_key: trimmed }); } } providers } fn commit_message_from_provider( provider: &CommitMessageProvider, diff: &str, status: &str, truncated: bool, ) -> Result<String> { let message = match provider { CommitMessageProvider::OpenAi { api_key } => { generate_commit_message(api_key, diff, status, truncated) } CommitMessageProvider::Remote { api_url, token } => { generate_commit_message_remote(api_url, token, diff, status, truncated) } }?; Ok(sanitize_commit_message(&message)) } fn commit_message_from_selection( selection: &CommitMessageSelection, providers: &[CommitMessageProvider], diff: &str, status: &str, truncated: bool, ) -> Result<String> { match selection { CommitMessageSelection::Kimi { model } => { generate_commit_message_kimi(diff, status, truncated, model.as_deref()) } CommitMessageSelection::Claude => generate_commit_message_claude(diff, status, truncated), CommitMessageSelection::Opencode { model } => { generate_commit_message_opencode(diff, status, truncated, model) } CommitMessageSelection::OpenRouter { model } => { generate_commit_message_openrouter(diff, status, truncated, model) } CommitMessageSelection::Rise { model } => { generate_commit_message_rise(diff, status, truncated, model) } CommitMessageSelection::Remote => { let provider = providers .iter() .find(|provider| matches!(provider, CommitMessageProvider::Remote { .. })) .ok_or_else(|| anyhow!("myflow provider unavailable; run `f auth`"))?; commit_message_from_provider(provider, diff, status, truncated) } CommitMessageSelection::OpenAi => { let provider = providers .iter() .find(|provider| matches!(provider, CommitMessageProvider::OpenAi { .. })) .ok_or_else(|| anyhow!("OPENAI_API_KEY is not configured"))?; commit_message_from_provider(provider, diff, status, truncated) } CommitMessageSelection::Heuristic => Ok(build_deterministic_commit_message(diff)), } } fn truncate_commit_subject(subject: &str) -> String { if subject.chars().count() <= 72 { return subject.to_string(); } let mut truncated: String = subject.chars().take(69).collect(); while truncated.ends_with(' ') { truncated.pop(); } format!("{}...", truncated) } fn build_deterministic_commit_message(diff: &str) -> String { let mut files = changed_files_from_diff(diff); files.sort(); files.dedup(); let subject = if files.is_empty() { "Update project files".to_string() } else if files.len() == 1 { format!("Update {}", files[0]) } else { format!("Update {} files", files.len()) }; let subject = truncate_commit_subject(&subject); if files.is_empty() { return subject; } let mut lines = Vec::new(); for file in files.iter().take(3) { lines.push(format!("- {}", file)); } if files.len() > 3 { lines.push(format!("- and {} more files", files.len() - 3)); } if lines.is_empty() { subject } else { format!("{}\n\n{}", subject, lines.join("\n")) } } fn generate_commit_message_with_fallbacks( repo_root: &Path, review_selection: Option<&ReviewSelection>, commit_message_override: Option<&CommitMessageOverride>, diff: &str, status: &str, truncated: bool, ) -> Result<String> { let providers = resolve_commit_message_providers(); let override_selection = commit_message_override.map(|override_tool| match override_tool { CommitMessageOverride::Selection(selection) => selection, }); let attempts = commit_message_attempts(repo_root, review_selection, override_selection); let mut errors: Vec<String> = Vec::new(); for (idx, selection) in attempts.iter().enumerate() { match commit_message_from_selection(selection, &providers, diff, status, truncated) { Ok(message) => { let sanitized = sanitize_commit_message(&message); if sanitized.trim().is_empty() { errors.push(format!( "{} returned an empty commit message", selection.key() )); continue; } if idx > 0 { println!( "✓ Commit message fallback succeeded via {}", selection.label() ); } return Ok(sanitized); } Err(err) => { if idx + 1 < attempts.len() { println!( "⚠ {} commit message failed: {}. Trying next fallback...", selection.label(), err ); } errors.push(format!("{}: {}", selection.key(), err)); } } } if commit_message_fail_open_enabled(repo_root) { println!("⚠ Commit message generation failed; using deterministic fallback message."); return Ok(build_deterministic_commit_message(diff)); } if errors.is_empty() { bail!( "commit message generation failed: no valid tools/providers configured for this repo" ); } bail!( "commit message generation failed:\n {}", errors.join("\n ") ) } fn sanitize_commit_message(message: &str) -> String { let filtered: Vec<&str> = message .lines() .filter(|line| !line.trim().contains("[Image #")) .collect(); let cleaned = filtered.join("\n").trim().to_string(); if cleaned.is_empty() { return message.trim().to_string(); } cleaned } fn generate_commit_message_kimi( diff: &str, status: &str, truncated: bool, model: Option<&str>, ) -> Result<String> { let mut prompt = String::from( "Write a git commit message for these changes. Output ONLY the commit message, nothing else.\n\n\ Guidelines:\n\ - Use imperative mood (\"Add feature\" not \"Added feature\")\n\ - First line: concise summary under 72 chars\n\ - Focus on WHAT and WHY, not just listing files\n\ - Never include secrets or credentials\n\n\ Git diff:\n", ); prompt.push_str(diff); if truncated { prompt.push_str("\n\n[Diff truncated]"); } let status = status.trim(); if !status.is_empty() { prompt.push_str("\n\nGit status:\n"); prompt.push_str(status); } info!( model = model.unwrap_or("default"), prompt_len = prompt.len(), "calling kimi for commit message" ); let mut cmd = Command::new("kimi"); cmd.args(["--quiet"]); if let Some(model) = model { if !model.trim().is_empty() { cmd.args(["--model", model]); } } cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd .spawn() .context("failed to run kimi for commit message")?; if let Some(mut stdin) = child.stdin.take() { stdin .write_all(prompt.as_bytes()) .context("failed to write prompt to kimi")?; } let output = child .wait_with_output() .context("failed to wait for kimi output")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let error_msg = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() }; bail!("kimi failed: {}", error_msg); } let message = String::from_utf8_lossy(&output.stdout).trim().to_string(); if message.is_empty() { bail!("kimi returned empty commit message"); } Ok(message) } fn git_run(args: &[&str]) -> Result<()> { let mut cmd = Command::new("git"); if args.first() == Some(&"commit") { cmd.env("FLOW_COMMIT", "1"); if entire_enabled() { cmd.env("ENTIRE_TEST_TTY", "1"); } } let status = cmd .args(args) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !status.success() { bail!("git {} failed with status {}", args.join(" "), status); } Ok(()) } fn git_run_in(workdir: &std::path::Path, args: &[&str]) -> Result<()> { let mut cmd = Command::new("git"); if args.first() == Some(&"commit") { cmd.env("FLOW_COMMIT", "1"); if entire_enabled() { cmd.env("ENTIRE_TEST_TTY", "1"); } } let status = cmd .current_dir(workdir) .args(args) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !status.success() { bail!("git {} failed with status {}", args.join(" "), status); } Ok(()) } /// Try to run a git command, returning Ok/Err without bailing. fn git_try(args: &[&str]) -> Result<()> { let status = Command::new("git") .args(args) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } /// Push result indicating success, remote ahead, or no remote repo. enum PushResult { Success, RemoteAhead, NoRemoteRepo, } fn branch_is_detached(branch: &str) -> bool { let trimmed = branch.trim(); trimmed.is_empty() || trimmed == "HEAD" } fn git_push_args<'a>(remote: &'a str, branch: &'a str) -> Vec<&'a str> { if branch_is_detached(branch) { vec!["push", remote, "HEAD"] } else { vec!["push", "-u", remote, branch.trim()] } } fn git_pull_rebase_args<'a>(remote: &'a str, branch: &'a str) -> Vec<&'a str> { if branch_is_detached(branch) { vec!["pull", "--rebase"] } else { vec!["pull", "--rebase", remote, branch.trim()] } } fn git_push_run(remote: &str, branch: &str) -> Result<()> { let args = git_push_args(remote, branch); git_run(&args) } fn git_push_run_in(workdir: &std::path::Path, remote: &str, branch: &str) -> Result<()> { let args = git_push_args(remote, branch); git_run_in(workdir, &args) } fn git_pull_rebase_try(remote: &str, branch: &str) -> Result<()> { let args = git_pull_rebase_args(remote, branch); git_try(&args) } fn git_pull_rebase_try_in(workdir: &std::path::Path, remote: &str, branch: &str) -> Result<()> { let args = git_pull_rebase_args(remote, branch); git_try_in(workdir, &args) } /// Try to push and detect if failure is due to missing remote repo. fn git_push_try(remote: &str, branch: &str) -> PushResult { let args = git_push_args(remote, branch); let output = Command::new("git").args(args).output().ok(); let Some(output) = output else { return PushResult::RemoteAhead; }; if output.status.success() { return PushResult::Success; } let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); if stderr.contains("repository not found") || stderr.contains("does not exist") || stderr.contains("could not read from remote") { PushResult::NoRemoteRepo } else { PushResult::RemoteAhead } } fn git_push_try_in(workdir: &std::path::Path, remote: &str, branch: &str) -> PushResult { let args = git_push_args(remote, branch); let output = Command::new("git") .current_dir(workdir) .args(args) .output() .ok(); let Some(output) = output else { return PushResult::RemoteAhead; }; if output.status.success() { return PushResult::Success; } let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); if stderr.contains("repository not found") || stderr.contains("does not exist") || stderr.contains("could not read from remote") { PushResult::NoRemoteRepo } else { PushResult::RemoteAhead } } fn git_try_in(workdir: &std::path::Path, args: &[&str]) -> Result<()> { let status = Command::new("git") .current_dir(workdir) .args(args) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } #[derive(Default)] struct GitCaptureCacheState { depth: usize, entries: HashMap<String, String>, } thread_local! { static GIT_CAPTURE_CACHE: RefCell<GitCaptureCacheState> = RefCell::new(GitCaptureCacheState::default()); } struct GitCaptureCacheScope; impl GitCaptureCacheScope { fn begin() -> Self { GIT_CAPTURE_CACHE.with(|state| { let mut state = state.borrow_mut(); if state.depth == 0 { state.entries.clear(); } state.depth += 1; }); Self } } impl Drop for GitCaptureCacheScope { fn drop(&mut self) { GIT_CAPTURE_CACHE.with(|state| { let mut state = state.borrow_mut(); state.depth = state.depth.saturating_sub(1); if state.depth == 0 { state.entries.clear(); } }); } } fn git_capture_cacheable(args: &[&str]) -> bool { args == ["rev-parse", "--show-toplevel"] || args == ["rev-parse", "--git-dir"] || (args.len() == 3 && args[0] == "remote" && args[1] == "get-url") } fn git_capture_cache_key(workdir: Option<&std::path::Path>, args: &[&str]) -> Option<String> { if !git_capture_cacheable(args) { return None; } let cwd = workdir .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); Some(format!("{cwd}|{}", args.join("\x1f"))) } fn git_capture_cached_lookup(key: &str) -> Option<String> { GIT_CAPTURE_CACHE.with(|state| { let state = state.borrow(); if state.depth == 0 { return None; } state.entries.get(key).cloned() }) } fn git_capture_cached_store(key: String, value: String) { GIT_CAPTURE_CACHE.with(|state| { let mut state = state.borrow_mut(); if state.depth > 0 { state.entries.insert(key, value); } }); } fn git_capture(args: &[&str]) -> Result<String> { if let Some(key) = git_capture_cache_key(None, args) { if let Some(cached) = git_capture_cached_lookup(&key) { return Ok(cached); } } let output = Command::new("git") .args(args) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { bail!("git {} failed", args.join(" ")); } let out = String::from_utf8_lossy(&output.stdout).to_string(); if let Some(key) = git_capture_cache_key(None, args) { git_capture_cached_store(key, out.clone()); } Ok(out) } fn git_capture_in(workdir: &std::path::Path, args: &[&str]) -> Result<String> { if let Some(key) = git_capture_cache_key(Some(workdir), args) { if let Some(cached) = git_capture_cached_lookup(&key) { return Ok(cached); } } let output = Command::new("git") .current_dir(workdir) .args(args) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { bail!("git {} failed", args.join(" ")); } let out = String::from_utf8_lossy(&output.stdout).to_string(); if let Some(key) = git_capture_cache_key(Some(workdir), args) { git_capture_cached_store(key, out.clone()); } Ok(out) } /// Find the largest valid UTF-8 char boundary at or before `pos`. fn floor_char_boundary(s: &str, pos: usize) -> usize { let mut end = pos.min(s.len()); while end > 0 && !s.is_char_boundary(end) { end -= 1; } end } fn truncate_diff(diff: &str) -> (String, bool) { if diff.len() <= MAX_DIFF_CHARS { (diff.to_string(), false) } else { let end = floor_char_boundary(diff, MAX_DIFF_CHARS); let truncated = format!( "{}\n\n[Diff truncated to first {} characters]", &diff[..end], end ); (truncated, true) } } fn truncate_context(context: &str, max_chars: usize) -> String { if context.len() <= max_chars { context.to_string() } else { let end = floor_char_boundary(context, max_chars); format!( "{}\n\n[Context truncated to first {} characters]", &context[..end], end ) } } /// Generate commit message using opencode or OpenRouter directly. #[allow(dead_code)] fn generate_commit_message_opencode( diff: &str, status: &str, truncated: bool, model: &str, ) -> Result<String> { // For OpenRouter models, call API directly to avoid tool use issues if model.starts_with("openrouter/") { return generate_commit_message_openrouter(diff, status, truncated, model); } // For zen models (and others), use opencode run with --print flag generate_commit_message_opencode_run(diff, status, truncated, model) } /// Generate commit message using opencode run command. fn generate_commit_message_opencode_run( diff: &str, status: &str, truncated: bool, model: &str, ) -> Result<String> { let mut prompt = String::from( "Write a git commit message for these changes. Output ONLY the commit message, nothing else.\n\n\ Guidelines:\n\ - Use imperative mood (\"Add feature\" not \"Added feature\")\n\ - First line: concise summary under 72 chars\n\ - Focus on WHAT and WHY, not just listing files\n\n\ Git diff:\n", ); prompt.push_str(diff); if truncated { prompt.push_str("\n\n[Diff truncated]"); } let status = status.trim(); if !status.is_empty() { prompt.push_str("\n\nGit status:\n"); prompt.push_str(status); } info!( model = model, prompt_len = prompt.len(), "calling opencode run for commit message" ); let start = std::time::Instant::now(); // Use --format json to get output in non-interactive mode let output = Command::new("opencode") .args(["run", "--model", model, "--format", "json", &prompt]) .output() .context("failed to run opencode for commit message")?; info!( elapsed_ms = start.elapsed().as_millis() as u64, success = output.status.success(), stdout_len = output.stdout.len(), stderr_len = output.stderr.len(), "opencode run completed" ); if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let error_msg = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() }; bail!("opencode failed: {}", error_msg); } // Parse JSON lines to extract text content let stdout = String::from_utf8_lossy(&output.stdout); let mut message = String::new(); for line in stdout.lines() { if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) { if json.get("type").and_then(|t| t.as_str()) == Some("text") { if let Some(text) = json .get("part") .and_then(|p| p.get("text")) .and_then(|t| t.as_str()) { message.push_str(text); } } } } let message = message.trim().to_string(); if message.is_empty() { bail!("opencode returned empty commit message"); } Ok(trim_quotes(&message)) } /// Generate commit message using Claude Code CLI. fn generate_commit_message_claude(diff: &str, status: &str, truncated: bool) -> Result<String> { let mut prompt = String::from( "Write a git commit message for these changes. Output ONLY the commit message, nothing else.\n\n\ Guidelines:\n\ - Use imperative mood (\"Add feature\" not \"Added feature\")\n\ - First line: concise summary under 72 chars\n\ - Focus on WHAT and WHY, not just listing files\n\n\ Git diff:\n", ); prompt.push_str(diff); if truncated { prompt.push_str("\n\n[Diff truncated]"); } let status = status.trim(); if !status.is_empty() { prompt.push_str("\n\nGit status:\n"); prompt.push_str(status); } let output = Command::new("claude") .args(["-p", &prompt]) .output() .context("failed to run claude for commit message")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("claude failed: {}", stderr.trim()); } let message = String::from_utf8_lossy(&output.stdout).trim().to_string(); if message.is_empty() { bail!("claude returned empty commit message"); } Ok(trim_quotes(&message)) } /// Generate commit message using Rise daemon (local AI proxy). fn generate_commit_message_rise( diff: &str, status: &str, truncated: bool, model: &str, ) -> Result<String> { let mut user_prompt = String::from("Write a git commit message for the staged changes.\n\nGit diff:\n"); user_prompt.push_str(diff); if truncated { user_prompt.push_str("\n\n[Diff truncated]"); } let status = status.trim(); if !status.is_empty() { user_prompt.push_str("\n\nGit status:\n"); user_prompt.push_str(status); } let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(120)) .context("failed to create HTTP client")?; let body = ChatRequest { model: model.to_string(), messages: vec![ Message { role: "system".to_string(), content: SYSTEM_PROMPT.to_string(), }, Message { role: "user".to_string(), content: user_prompt, }, ], temperature: 0.3, }; info!(model = model, "calling Rise daemon for commit message"); let start = std::time::Instant::now(); let rise_url = rise_url(); let text = send_rise_request_text(&client, &rise_url, &body, model)?; info!( elapsed_ms = start.elapsed().as_millis() as u64, "Rise daemon responded" ); let message = parse_rise_output(&text).context("failed to parse Rise response")?; let message = message.trim().to_string(); if message.is_empty() { bail!("Rise daemon returned empty commit message"); } Ok(trim_quotes(&message)) } /// Generate commit message using OpenRouter API directly. fn generate_commit_message_openrouter( diff: &str, status: &str, truncated: bool, model: &str, ) -> Result<String> { let api_key = openrouter_api_key()?; let model_id = openrouter_model_id(model); let mut user_prompt = String::from("Write a git commit message for the staged changes.\n\nGit diff:\n"); user_prompt.push_str(diff); if truncated { user_prompt.push_str("\n\n[Diff truncated]"); } let status = status.trim(); if !status.is_empty() { user_prompt.push_str("\n\nGit status:\n"); user_prompt.push_str(status); } let client = openrouter_http_client(Duration::from_secs(60))?; let body = ChatRequest { model: model_id.to_string(), messages: vec![ Message { role: "system".to_string(), content: SYSTEM_PROMPT.to_string(), }, Message { role: "user".to_string(), content: user_prompt, }, ], temperature: 0.3, }; let parsed: ChatResponse = openrouter_chat_completion_with_retry(&client, &api_key, &body) .context("OpenRouter request failed")?; let message = parsed .choices .first() .and_then(|c| c.message.as_ref()) .map(|m| m.content.trim().to_string()) .unwrap_or_default(); if message.is_empty() { bail!("OpenRouter returned empty commit message"); } Ok(trim_quotes(&message)) } fn generate_commit_message( api_key: &str, diff: &str, status: &str, truncated: bool, ) -> Result<String> { let mut user_prompt = String::from("Write a git commit message for the staged changes.\n\nGit diff:\n"); user_prompt.push_str(diff); if truncated { user_prompt.push_str("\n\n[Diff truncated to fit within prompt]"); } let status = status.trim(); if !status.is_empty() { user_prompt.push_str("\n\nGit status --short:\n"); user_prompt.push_str(status); } let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(60)) .context("failed to create HTTP client")?; let body = ChatRequest { model: MODEL.to_string(), messages: vec![ Message { role: "system".to_string(), content: SYSTEM_PROMPT.to_string(), }, Message { role: "user".to_string(), content: user_prompt, }, ], temperature: 0.3, }; // Retry logic for transient failures const MAX_RETRIES: u32 = 3; let mut last_error = None; for attempt in 0..MAX_RETRIES { if attempt > 0 { let delay = Duration::from_secs(2u64.pow(attempt)); print!("Retrying in {}s... ", delay.as_secs()); io::stdout().flush().ok(); std::thread::sleep(delay); } match client .post("https://api.openai.com/v1/chat/completions") .header("Authorization", format!("Bearer {}", api_key)) .json(&body) .send() { Ok(resp) => { if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_default(); // Don't retry client errors (4xx) if status.is_client_error() { bail!("OpenAI API error {}: {}", status, text); } last_error = Some(format!("OpenAI API error {}: {}", status, text)); continue; } let parsed: ChatResponse = resp.json().context("failed to parse OpenAI response")?; let message = parsed .choices .first() .and_then(|c| c.message.as_ref()) .map(|m| m.content.trim().to_string()) .unwrap_or_default(); if message.is_empty() { bail!("OpenAI returned empty commit message"); } return Ok(trim_quotes(&message)); } Err(e) => { last_error = Some(format!("failed to call OpenAI API: {}", e)); if attempt < MAX_RETRIES - 1 { println!("API call failed, will retry..."); } } } } bail!( "{}", last_error.unwrap_or_else(|| "OpenAI API failed after retries".to_string()) ) } fn generate_commit_message_remote( api_url: &str, token: &str, diff: &str, status: &str, truncated: bool, ) -> Result<String> { let trimmed = api_url.trim().trim_end_matches('/'); let url = format!("{}/api/ai/commit-message", trimmed); let client = crate::http_client::blocking_with_timeout(Duration::from_secs( commit_with_check_timeout_secs(), )) .context("failed to create HTTP client for remote commit message")?; let payload = json!({ "diff": diff, "status": status, "truncated": truncated, }); let response = client .post(&url) .bearer_auth(token) .json(&payload) .send() .context("failed to request remote commit message")?; if !response.status().is_success() { if response.status() == StatusCode::UNAUTHORIZED { bail!("remote commit message unauthorized. Run `f auth` to login."); } if response.status() == StatusCode::PAYMENT_REQUIRED { bail!( "remote commit message requires an active subscription. Visit myflow to subscribe." ); } let status = response.status(); let body = response.text().unwrap_or_default(); bail!("remote commit message failed: HTTP {} {}", status, body); } let payload: RemoteCommitMessageResponse = response .json() .context("failed to parse remote commit message response")?; let message = payload.message.trim().to_string(); if message.is_empty() { bail!("remote commit message was empty"); } Ok(trim_quotes(&message)) } fn trim_quotes(s: &str) -> String { let s = s.trim(); if s.len() >= 2 { let first = s.chars().next().unwrap(); let last = s.chars().last().unwrap(); if (first == '"' && last == '"') || (first == '\'' && last == '\'') { return s[1..s.len() - 1].to_string(); } } s.to_string() } fn capture_staged_snapshot_in(workdir: &std::path::Path) -> Result<StagedSnapshot> { let staged_diff = git_capture_in(workdir, &["diff", "--cached"])?; if staged_diff.trim().is_empty() { return Ok(StagedSnapshot { patch_path: None }); } let mut file = NamedTempFile::new().context("failed to create temp file for staged diff")?; file.write_all(staged_diff.as_bytes()) .context("failed to write staged diff snapshot")?; let path = file .into_temp_path() .keep() .context("failed to persist staged diff snapshot")?; Ok(StagedSnapshot { patch_path: Some(path), }) } fn restore_staged_snapshot_in(workdir: &std::path::Path, snapshot: &StagedSnapshot) -> Result<()> { let _ = git_try_in(workdir, &["reset", "HEAD"]); if let Some(path) = &snapshot.patch_path { let path_str = path .to_str() .context("failed to convert staged snapshot path to string")?; let _ = git_try_in(workdir, &["apply", "--cached", path_str]); let _ = std::fs::remove_file(path); } Ok(()) } fn cleanup_staged_snapshot(snapshot: &StagedSnapshot) { if let Some(path) = &snapshot.patch_path { let _ = std::fs::remove_file(path); } } /// Extract text content from kimi's stream-json output. /// Format: {"role":"assistant","content":[{"type":"think","think":"..."},{"type":"text","text":"..."}]} fn extract_kimi_text_content(output: &str) -> Option<String> { let trimmed = output.trim(); if trimmed.is_empty() { return None; } // Try to parse as JSON let json: serde_json::Value = serde_json::from_str(trimmed).ok()?; // Extract content array let content = json.get("content")?.as_array()?; // Find the "text" type content and concatenate all text let mut text_parts = Vec::new(); for item in content { if item.get("type").and_then(|t| t.as_str()) == Some("text") { if let Some(text) = item.get("text").and_then(|t| t.as_str()) { text_parts.push(text.to_string()); } } } if text_parts.is_empty() { None } else { Some(text_parts.join("\n")) } } fn normalize_future_tasks(tasks: &[String]) -> Vec<String> { let mut seen = HashSet::new(); let mut normalized = Vec::new(); for task in tasks { let trimmed = task.trim().trim_start_matches('-').trim(); if trimmed.is_empty() { continue; } let key = trimmed.to_lowercase(); if seen.insert(key) { normalized.push(trimmed.to_string()); } } normalized } fn openrouter_model_id(model: &str) -> &str { // Only strip "openrouter/" prefix when there's a nested provider path // e.g. "openrouter/meta-llama/llama-3.3-70b" → "meta-llama/llama-3.3-70b" // but keep "openrouter/pony-alpha" as-is (first-party OpenRouter model). if let Some(rest) = model .strip_prefix("openrouter/") .or_else(|| model.strip_prefix("openrouter:")) { if rest.contains('/') { return rest; } } model } fn openrouter_model_label(model: &str) -> String { format!("openrouter/{}", openrouter_model_id(model)) } fn openrouter_api_key() -> Result<String> { if let Ok(value) = std::env::var("OPENROUTER_API_KEY") { if !value.trim().is_empty() { return Ok(value); } } if is_local_env_backend() { if let Ok(vars) = crate::env::fetch_personal_env_vars(&["OPENROUTER_API_KEY".to_string()]) { if let Some(value) = vars.get("OPENROUTER_API_KEY") { if !value.trim().is_empty() { return Ok(value.clone()); } } } } bail!("OPENROUTER_API_KEY not set. Get one at https://openrouter.ai/keys"); } fn parse_review_json(output: &str) -> Option<ReviewJson> { let trimmed = output.trim(); if trimmed.is_empty() { return None; } if let Ok(parsed) = serde_json::from_str::<ReviewJson>(trimmed) { return Some(parsed); } let start = trimmed.find('{')?; let end = trimmed.rfind('}')?; if end <= start { return None; } let candidate = &trimmed[start..=end]; serde_json::from_str::<ReviewJson>(candidate).ok() } fn record_review_outputs_to_beads_rust( repo_root: &Path, review: &ReviewResult, reviewer: &str, model_label: &str, committed_sha: Option<&str>, review_run_id: &str, ) { if env_flag("FLOW_BEADS_RUST_DISABLE") { return; } let beads_dir = beads_rust_beads_dir(repo_root); if let Err(err) = fs::create_dir_all(&beads_dir) { println!( "⚠️ Failed to prepare repo-local beads dir {}: {}", beads_dir.display(), err ); return; } let project_path = repo_root.display().to_string(); let project_name = repo_root .file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "project".to_string()); let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()) .trim() .to_string(); let sha_short = committed_sha.map(short_sha).unwrap_or("unknown"); let project_label = safe_label_value(&project_name); let branch_label = safe_label_value(&branch); let reviewer_label = safe_label_value(reviewer); let mut created = 0usize; // Snapshot bead: one per review run, always. match create_review_run_bead( &beads_dir, review, &project_path, &project_name, &branch, sha_short, reviewer, model_label, review_run_id, ) { Ok(true) => created += 1, Ok(false) => {} Err(err) => println!("⚠️ Failed to create review snapshot bead: {}", err), } // Issue beads: one per issue in this review run. for issue in &review.issues { match create_review_issue_bead( &beads_dir, issue, &project_path, &project_name, &branch, sha_short, reviewer, model_label, review.summary.as_deref(), review_run_id, ) { Ok(true) => created += 1, Ok(false) => {} Err(err) => println!("⚠️ Failed to create review issue bead: {}", err), } } // Future-task beads: one per suggestion in this review run. for task in &review.future_tasks { match create_review_future_task_bead( &beads_dir, task, &project_path, &project_label, &branch, &branch_label, sha_short, &reviewer_label, model_label, review.summary.as_deref(), review_run_id, ) { Ok(true) => created += 1, Ok(false) => {} Err(err) => println!("⚠️ Failed to create review task bead: {}", err), } } if created > 0 { println!( "Recorded {} review bead(s) to {}", created, beads_dir.display() ); } } fn create_review_run_bead( beads_dir: &Path, review: &ReviewResult, project_path: &str, project_name: &str, branch: &str, sha_short: &str, reviewer: &str, model_label: &str, review_run_id: &str, ) -> Result<bool> { let title = format!("Review: {} {}", project_name, sha_short); let external_ref = format!( "flow-review-run:{}", flow_review_item_id(review_run_id, "run", "snapshot") ); let labels = format!( "flow-review,review:run,task,project:{},commit:{},branch:{},reviewer:{}", safe_label_value(project_name), sha_short, safe_label_value(branch), safe_label_value(reviewer) ); let mut desc = String::new(); desc.push_str("Review snapshot\n\n"); desc.push_str("Project: "); desc.push_str(project_path); desc.push_str("\nBranch: "); desc.push_str(branch); desc.push_str("\nCommit: "); desc.push_str(sha_short); desc.push_str("\nReviewer: "); desc.push_str(reviewer); desc.push_str("\nModel: "); desc.push_str(model_label); desc.push_str("\nRun ID: "); desc.push_str(review_run_id); if let Some(summary) = review .summary .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { desc.push_str("\n\nSummary:\n"); desc.push_str(summary); } if !review.issues.is_empty() { desc.push_str("\n\nIssues:\n"); for issue in &review.issues { desc.push_str("- "); desc.push_str(issue.trim()); desc.push('\n'); } } if !review.future_tasks.is_empty() { desc.push_str("\nFuture tasks:\n"); for task in &review.future_tasks { desc.push_str("- "); desc.push_str(task.trim()); desc.push('\n'); } } if review.timed_out { desc.push_str("\nNote: Review timed out.\n"); } br_create_ephemeral( beads_dir, &title, &desc, "task", "4", "open", &external_ref, &labels, ) .context("run br create for review snapshot") } fn create_review_issue_bead( beads_dir: &Path, issue: &str, project_path: &str, project_name: &str, branch: &str, sha_short: &str, reviewer: &str, model_label: &str, summary: Option<&str>, review_run_id: &str, ) -> Result<bool> { let title = review_task_title(issue); let external_ref = format!( "flow-review-issue:{}", flow_review_item_id(review_run_id, "issue", issue) ); let labels = format!( "flow-review,review:issue,bug,project:{},commit:{},branch:{},reviewer:{}", safe_label_value(project_name), sha_short, safe_label_value(branch), safe_label_value(reviewer) ); let priority = infer_review_bead_priority(issue).to_string(); let mut desc = String::new(); desc.push_str(issue.trim()); desc.push_str("\n\nProject: "); desc.push_str(project_path); desc.push_str("\nBranch: "); desc.push_str(branch); desc.push_str("\nCommit: "); desc.push_str(sha_short); desc.push_str("\nReviewer: "); desc.push_str(reviewer); desc.push_str("\nModel: "); desc.push_str(model_label); desc.push_str("\nRun ID: "); desc.push_str(review_run_id); if let Some(summary) = summary.map(|s| s.trim()).filter(|s| !s.is_empty()) { desc.push_str("\nReview summary: "); desc.push_str(summary); } br_create_ephemeral( beads_dir, &title, &desc, "bug", &priority, "open", &external_ref, &labels, ) .context("run br create for review issue") } fn create_review_future_task_bead( beads_dir: &Path, task: &str, project_path: &str, project_label: &str, branch: &str, branch_label: &str, sha_short: &str, reviewer_label: &str, model_label: &str, summary: Option<&str>, review_run_id: &str, ) -> Result<bool> { let title = review_task_title(task); let description = review_task_description_with_commit( task, project_path, branch, sha_short, reviewer_label, summary, model_label, review_run_id, ); let external_ref = format!( "flow-review-task:{}", flow_review_item_id(review_run_id, "task", task) ); let labels = format!( "flow-review,review:task,task,project:{},commit:{},branch:{},reviewer:{}", project_label, sha_short, branch_label, reviewer_label ); br_create_ephemeral( beads_dir, &title, &description, "task", "4", "open", &external_ref, &labels, ) .context("run br create for review task") } fn br_create_ephemeral( beads_dir: &Path, title: &str, description: &str, issue_type: &str, priority: &str, status: &str, external_ref: &str, labels: &str, ) -> Result<bool> { let output = Command::new("br") .arg("create") .arg("--title") .arg(title) .arg("--description") .arg(description) .arg("--type") .arg(issue_type) .arg("--priority") .arg(priority) .arg("--status") .arg(status) .arg("--external-ref") .arg(external_ref) .arg("--labels") .arg(labels) .arg("--ephemeral") .arg("--silent") .arg("--no-auto-flush") .arg("--no-auto-import") .env("BEADS_DIR", beads_dir) .output() .context("run br create")?; if output.status.success() { return Ok(true); } if br_create_failed_due_to_duplicate_external_ref(&output) { return Ok(false); } let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let msg = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() }; bail!("beads create failed: {}", msg); } fn infer_review_bead_priority(issue: &str) -> u8 { let lower = issue.to_lowercase(); if lower.contains("secret") || lower.contains("credential") || lower.contains("api key") || lower.contains("injection") || lower.contains("vulnerability") { return 0; // critical } if lower.contains("crash") || lower.contains("data loss") || lower.contains("race condition") || lower.contains("buffer overflow") { return 1; // high } if lower.contains("bug") || lower.contains("error handling") || lower.contains("panic") || lower.contains("unwrap") || lower.contains("missing validation") { return 2; // medium } if lower.contains("style") || lower.contains("refactor") || lower.contains("unused") || lower.contains("naming") || lower.contains("dead code") { return 3; // low } 3 // default for issues } fn br_create_failed_due_to_duplicate_external_ref(output: &std::process::Output) -> bool { let mut combined = String::new(); combined.push_str(&String::from_utf8_lossy(&output.stdout)); combined.push('\n'); combined.push_str(&String::from_utf8_lossy(&output.stderr)); let lower = combined.to_lowercase(); lower.contains("unique constraint failed") && (lower.contains("issues.external_ref") || lower.contains("external_ref")) } fn safe_label_value(value: &str) -> String { let mut out = String::new(); for ch in value.trim().chars() { if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { out.push(ch); } else { out.push('_'); } } if out.is_empty() { "unknown".to_string() } else { out } } fn flow_review_project_key(repo_root: &Path) -> String { if let Ok(url) = git_capture_in(repo_root, &["config", "--get", "remote.origin.url"]) { let url = url.trim(); if !url.is_empty() { if let Some(key) = normalize_git_remote_to_owner_repo(url) { return key; } return url.to_string(); } } repo_root.display().to_string() } fn normalize_git_remote_to_owner_repo(url: &str) -> Option<String> { let trimmed = url.trim().trim_end_matches('/'); // git@github.com:owner/repo.git if let Some(rest) = trimmed.strip_prefix("git@github.com:") { let rest = rest.trim_end_matches(".git"); if rest.split('/').count() == 2 { return Some(rest.to_string()); } } // https://github.com/owner/repo(.git) if let Some(rest) = trimmed.strip_prefix("https://github.com/") { let rest = rest.trim_end_matches(".git"); let parts: Vec<&str> = rest.split('/').collect(); if parts.len() >= 2 { return Some(format!("{}/{}", parts[0], parts[1])); } } None } fn flow_review_run_id(repo_root: &Path, diff: &str, model_label: &str, reviewer: &str) -> String { let project_key = flow_review_project_key(repo_root); let mut hasher = Sha1::new(); hasher.update(project_key.as_bytes()); hasher.update(b":"); hasher.update(reviewer.trim().as_bytes()); hasher.update(b":"); hasher.update(model_label.trim().as_bytes()); hasher.update(b":"); hasher.update(diff.as_bytes()); let hex = hex::encode(hasher.finalize()); hex.get(..12).unwrap_or(&hex).to_string() } fn flow_review_item_id(review_run_id: &str, kind: &str, text: &str) -> String { let mut hasher = Sha1::new(); hasher.update(kind.as_bytes()); hasher.update(b":"); hasher.update(review_run_id.as_bytes()); hasher.update(b":"); hasher.update(text.trim().as_bytes()); let hex = hex::encode(hasher.finalize()); hex.get(..12).unwrap_or(&hex).to_string() } fn review_task_title(task: &str) -> String { let trimmed = task.trim().trim_start_matches('-').trim(); let max_len = 120; let mut title = String::new(); let mut count = 0; for ch in trimmed.chars() { if count >= max_len { title.push_str("..."); break; } title.push(ch); count += 1; } title } fn review_task_description_with_commit( task: &str, project_path: &str, branch: &str, sha_short: &str, reviewer_label: &str, summary: Option<&str>, model_label: &str, review_run_id: &str, ) -> String { let mut desc = String::new(); desc.push_str(task.trim()); desc.push_str("\n\nProject: "); desc.push_str(project_path); desc.push_str("\nBranch: "); desc.push_str(branch); desc.push_str("\nCommit: "); desc.push_str(sha_short); desc.push_str("\nReviewer: "); desc.push_str(reviewer_label); desc.push_str("\nModel: "); desc.push_str(model_label); desc.push_str("\nRun ID: "); desc.push_str(review_run_id); if let Some(summary) = summary { desc.push_str("\nReview summary: "); desc.push_str(summary); } desc } fn env_flag(name: &str) -> bool { env::var(name) .ok() .map(|value| { matches!( value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ) }) .unwrap_or(false) } /// Send critical review issues to cloud for reactive display. fn send_to_cloud(project_path: &std::path::Path, issues: &[String], summary: Option<&str>) { // Try production worker first, then local let endpoints = [ "https://myflow.sh/api/v1/events", // Production worker "http://localhost:8787/api/v1/events", // Local dev ]; let project_name = project_path .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "unknown".to_string()); let payload = json!({ "type": "review_issue", "project": project_name, "issues": issues, "summary": summary, "timestamp": chrono::Utc::now().to_rfc3339(), }); let client = match crate::http_client::blocking_with_timeout(Duration::from_secs(2)) { Ok(c) => c, Err(_) => return, }; for endpoint in &endpoints { if client.post(*endpoint).json(&payload).send().is_ok() { debug!("Sent review issues to {}", endpoint); return; } } } enum ReviewEvent { Line(String), StderrLine(String), StdoutDone, StderrDone, } fn should_show_review_context() -> bool { std::env::var("FLOW_SHOW_REVIEW_CONTEXT") .map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) .unwrap_or(false) } /// Check if gitedit is globally enabled in ~/.config/flow/config.ts. /// Returns true by default if not specified (opt-out). fn gitedit_globally_enabled() -> bool { if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(enabled) = flow.gitedit { return enabled; } } } // Default to false (opt-in) - gitedit not working well currently false } /// Check if gitedit mirroring is enabled in flow.toml. fn gitedit_mirror_enabled() -> bool { let repo_root = git_root_or_cwd(); let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { return cfg.options.gitedit_mirror.unwrap_or(false); } } false } /// Check if gitedit mirroring is enabled for commit in the repo root. fn gitedit_mirror_enabled_for_commit(repo_root: &std::path::Path) -> bool { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { return cfg.options.gitedit_mirror.unwrap_or(false); } } false } /// Check if gitedit mirroring is enabled for commitWithCheck in flow.toml. fn gitedit_mirror_enabled_for_commit_with_check(repo_root: &std::path::Path) -> bool { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(value) = cfg.options.commit_with_check_gitedit_mirror { return value; } return cfg.options.gitedit_mirror.unwrap_or(false); } } false } /// Get the gitedit API URL from config or use default. fn gitedit_api_url(repo_root: &std::path::Path) -> String { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(url) = cfg.options.gitedit_url { return url; } } } "https://gitedit.dev".to_string() } fn gitedit_token(repo_root: &std::path::Path) -> Option<String> { for key in [ "GITEDIT_PUBLISH_TOKEN", "GITEDIT_TOKEN", "FLOW_GITEDIT_TOKEN", ] { if let Ok(value) = std::env::var(key) { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(token) = cfg.options.gitedit_token { let trimmed = token.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } } None } fn gitedit_repo_override(repo_root: &std::path::Path) -> Option<(String, String)> { let local_config = repo_root.join("flow.toml"); if !local_config.exists() { return None; } let cfg = config::load(&local_config).ok()?; let raw = cfg.options.gitedit_repo_full_name?; let mut value = raw.trim(); if let Some(rest) = value.strip_prefix("gh/") { value = rest; } if let Some(idx) = value.find("github.com/") { value = &value[idx + "github.com/".len()..]; } if let Some(rest) = value.strip_suffix(".git") { value = rest; } let mut parts = value.split('/').filter(|s| !s.is_empty()); let owner = parts.next()?.to_string(); let repo = parts.next()?.to_string(); Some((owner, repo)) } /// Data from AI code review to sync to gitedit. #[derive(Debug, Clone, Default)] pub struct GitEditReviewData { pub diff: Option<String>, pub issues_found: bool, pub issues: Vec<String>, pub summary: Option<String>, pub reviewer: Option<String>, // "claude" or "codex" } /// Sync commit to gitedit.dev for mirroring. fn sync_to_gitedit( repo_root: &std::path::Path, event: &str, ai_sessions: &[ai::GitEditSessionData], session_hash: Option<&str>, review_data: Option<&GitEditReviewData>, ) { let (owner, repo) = if let Some((owner, repo)) = gitedit_repo_override(repo_root) { (owner, repo) } else { // Get remote origin URL to extract owner/repo let remote_url = match git_capture_in(repo_root, &["remote", "get-url", "origin"]) { Ok(url) => url.trim().to_string(), Err(_) => { debug!("No git remote found, skipping gitedit sync"); return; } }; // Parse owner/repo from remote URL // Supports: git@github.com:owner/repo.git, https://github.com/owner/repo.git match parse_github_remote(&remote_url) { Some((o, r)) => (o, r), None => { debug!("Could not parse GitHub remote URL: {}", remote_url); return; } } }; // Get current commit SHA let commit_sha = match git_capture_in(repo_root, &["rev-parse", "HEAD"]) { Ok(sha) => sha.trim().to_string(), Err(_) => { debug!("Could not get commit SHA"); return; } }; // Get current branch let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .ok() .map(|b| b.trim().to_string()); let ref_name = branch.as_ref().map(|name| format!("refs/heads/{}", name)); // Get commit message let commit_message = git_capture_in(repo_root, &["log", "-1", "--format=%B"]) .ok() .map(|m| m.trim().to_string()); // Get author info let author_name = git_capture_in(repo_root, &["log", "-1", "--format=%an"]) .ok() .map(|n| n.trim().to_string()); let author_email = git_capture_in(repo_root, &["log", "-1", "--format=%ae"]) .ok() .map(|e| e.trim().to_string()); let session_count = ai_sessions.len(); let ai_sessions_json: Vec<serde_json::Value> = ai_sessions .iter() .map(|s| { json!({ "session_id": s.session_id, "provider": s.provider, "started_at": s.started_at, "last_activity_at": s.last_activity_at, "exchange_count": s.exchanges.len(), "context_summary": s.context_summary, "exchanges": s.exchanges.iter().map(|e| json!({ "user_message": e.user_message, "assistant_message": e.assistant_message, "timestamp": e.timestamp, })).collect::<Vec<_>>(), }) }) .collect(); let base_url = gitedit_api_url(repo_root); let base_url = base_url.trim_end_matches('/').to_string(); let api_url = format!("{}/api/mirrors/sync", base_url); let view_url = format!("{}/{}/{}", base_url, owner, repo); // Build review data if present let review_json = review_data.map(|r| { json!({ "diff": r.diff, "issues_found": r.issues_found, "issues": r.issues, "summary": r.summary, "reviewer": r.reviewer, }) }); let payload = json!({ "owner": owner, "repo": repo, "commit_sha": commit_sha, "branch": branch, "ref": ref_name, "event": event, "source": "flow-cli", "commit_message": commit_message, "author_name": author_name, "author_email": author_email, "session_hash": session_hash, "ai_sessions": ai_sessions_json, "review": review_json, }); let client = match crate::http_client::blocking_with_timeout(Duration::from_secs(10)) { Ok(c) => c, Err(_) => return, }; let mut request = client.post(&api_url).json(&payload); if let Some(token) = gitedit_token(repo_root) { request = request.bearer_auth(token); } match request.send() { Ok(resp) if resp.status().is_success() => { if session_count > 0 { println!( "✓ Synced to {} ({} AI session{})", view_url, session_count, if session_count == 1 { "" } else { "s" } ); } else { println!("✓ Synced to {}", view_url); } debug!("Gitedit sync successful"); } Ok(resp) => { debug!("Gitedit sync failed: HTTP {}", resp.status()); } Err(e) => { debug!("Gitedit sync error: {}", e); } } } fn gitedit_sessions_hash( owner: &str, repo: &str, sessions: &[ai::GitEditSessionData], ) -> Option<String> { if sessions.is_empty() { return None; } // Hash includes owner/repo so the URL uniquely identifies the project let serialized = serde_json::to_string(sessions).ok()?; let mut hasher = DefaultHasher::new(); owner.hash(&mut hasher); repo.hash(&mut hasher); serialized.hash(&mut hasher); Some(format!("{:016x}", hasher.finish())) } /// Get owner/repo from git remote or gitedit override. fn get_gitedit_project(repo_root: &std::path::Path) -> Option<(String, String)> { // Check for override first if let Some((owner, repo)) = gitedit_repo_override(repo_root) { return Some((owner, repo)); } // Get from git remote let remote_url = git_capture_in(repo_root, &["remote", "get-url", "origin"]).ok()?; parse_github_remote(remote_url.trim()) } /// Parse owner and repo from a GitHub remote URL. fn parse_github_remote(url: &str) -> Option<(String, String)> { let url = url.trim(); // SSH format: git@github.com:owner/repo.git if url.starts_with("git@github.com:") { let path = url.strip_prefix("git@github.com:")?; let path = path.strip_suffix(".git").unwrap_or(path); let parts: Vec<&str> = path.split('/').collect(); if parts.len() >= 2 { return Some((parts[0].to_string(), parts[1].to_string())); } } // HTTPS format: https://github.com/owner/repo.git if url.contains("github.com/") { let idx = url.find("github.com/")?; let path = &url[idx + 11..]; let path = path.strip_suffix(".git").unwrap_or(path); let parts: Vec<&str> = path.split('/').collect(); if parts.len() >= 2 { return Some((parts[0].to_string(), parts[1].to_string())); } } None } // ── myflow.sh sync ────────────────────────────────────────────────── /// Check if myflow mirroring is enabled in flow.toml. fn myflow_mirror_enabled(repo_root: &std::path::Path) -> bool { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { return cfg.options.myflow_mirror.unwrap_or(false); } } false } /// Get the myflow API URL from config or use default. fn myflow_api_url(repo_root: &std::path::Path) -> String { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(url) = cfg.options.myflow_url { return url; } } } "https://myflow.sh".to_string() } /// Get the myflow token from env, flow.toml, or ~/.config/flow/auth.toml. fn myflow_token(repo_root: &std::path::Path) -> Option<String> { // 1. Check env var if let Ok(value) = std::env::var("MYFLOW_TOKEN") { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } // 2. Check flow.toml let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(token) = cfg.options.myflow_token { let trimmed = token.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } } // 3. Fall back to ~/.config/flow/auth.toml token let config_dir = dirs::config_dir()?.join("flow"); let auth_path = config_dir.join("auth.toml"); if auth_path.exists() { if let Ok(content) = std::fs::read_to_string(&auth_path) { if let Ok(auth) = toml::from_str::<toml::Value>(&content) { if let Some(token) = auth.get("token").and_then(|v| v.as_str()) { let trimmed = token.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } } } None } fn post_myflow_sync_events( client: &Client, events_api_url: &str, token: Option<&str>, owner: &str, repo: &str, commit_sha: &str, events: Vec<serde_json::Value>, ) { if events.is_empty() { return; } let payload = json!({ "owner": owner, "repo": repo, "commit_sha": commit_sha, "correlation_id": commit_sha, "events": events, }); let mut request = client.post(events_api_url).json(&payload); if let Some(value) = token { request = request.bearer_auth(value); } match request.send() { Ok(resp) if resp.status().is_success() => {} Ok(resp) => { debug!("myflow sync-events failed: HTTP {}", resp.status()); } Err(err) => { debug!("myflow sync-events error: {}", err); } } } /// Sync commit data to myflow.sh, mirroring the gitedit sync pattern. /// Fire-and-forget: never fails the commit on sync error. fn sync_to_myflow( repo_root: &std::path::Path, event: &str, ai_sessions: &[ai::GitEditSessionData], session_window: Option<&MyflowSessionWindow>, review_data: Option<&GitEditReviewData>, skill_gate: Option<&SkillGateReport>, ) { // Get remote origin URL to extract owner/repo let remote_url = match git_capture_in(repo_root, &["remote", "get-url", "origin"]) { Ok(url) => url.trim().to_string(), Err(_) => { debug!("No git remote found, skipping myflow sync"); return; } }; let (owner, repo) = match parse_github_remote(&remote_url) { Some((o, r)) => (o, r), None => { debug!( "Could not parse GitHub remote URL for myflow: {}", remote_url ); return; } }; // Get current commit SHA let commit_sha = match git_capture_in(repo_root, &["rev-parse", "HEAD"]) { Ok(sha) => sha.trim().to_string(), Err(_) => { debug!("Could not get commit SHA for myflow"); return; } }; // Get current branch let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .ok() .map(|b| b.trim().to_string()); // Get commit message let commit_message = git_capture_in(repo_root, &["log", "-1", "--format=%B"]) .ok() .map(|m| m.trim().to_string()); // Get author info let author_name = git_capture_in(repo_root, &["log", "-1", "--format=%an"]) .ok() .map(|n| n.trim().to_string()); let author_email = git_capture_in(repo_root, &["log", "-1", "--format=%ae"]) .ok() .map(|e| e.trim().to_string()); let session_count = ai_sessions.len(); let ai_sessions_json: Vec<serde_json::Value> = ai_sessions .iter() .map(|s| { json!({ "session_id": s.session_id, "provider": s.provider, "started_at": s.started_at, "last_activity_at": s.last_activity_at, "exchange_count": s.exchanges.len(), "context_summary": s.context_summary, "exchanges": s.exchanges.iter().map(|e| json!({ "user_message": e.user_message, "assistant_message": e.assistant_message, "timestamp": e.timestamp, })).collect::<Vec<_>>(), }) }) .collect(); let base_url = myflow_api_url(repo_root); let base_url = base_url.trim_end_matches('/').to_string(); let api_url = format!("{}/api/sync", base_url); let events_api_url = format!("{}/api/sync/events", base_url); let started_at_ms = chrono::Utc::now().timestamp_millis(); // Build review data if present let review_json = review_data.map(|r| { json!({ "issues_found": r.issues_found, "issues": r.issues, "summary": r.summary, "reviewer": r.reviewer, }) }); // Build features data from .ai/features/ if present let features_json: Vec<serde_json::Value> = features::load_all_features(repo_root) .unwrap_or_default() .iter() .map(|f| { json!({ "name": f.name, "title": f.content.lines().next().unwrap_or(&f.name).trim_start_matches('#').trim(), "status": f.status, "description": f.description, "files": f.files, "tests": f.tests, "coverage": f.coverage, "last_verified_sha": f.last_verified, }) }) .collect(); let skill_gate_json = skill_gate.map(|gate| { json!({ "pass": gate.pass, "mode": gate.mode, "override": gate.override_flag, "required_skills": gate.required_skills, "missing_skills": gate.missing_skills, "version_failures": gate.version_failures, "loaded_versions": gate.loaded_versions, }) }); let payload = json!({ "owner": owner, "repo": repo, "commit_sha": commit_sha, "branch": branch, "event": event, "source": "flow-cli", "commit_message": commit_message, "author_name": author_name, "author_email": author_email, "ai_sessions": ai_sessions_json, "session_window": session_window.map(|window| json!({ "mode": window.mode, "since_ts": window.since_ts, "until_ts": window.until_ts, "collected_at": window.collected_at, })), "review": review_json, "features": if features_json.is_empty() { None } else { Some(features_json) }, "skill_gate": skill_gate_json, "sync_events": [ { "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "transport", "tier": "client", "direction": "outbound", "status": "pending", "at_ms": started_at_ms, "details": { "phase": "request_start", "target": "api/sync", "source": "flow-cli", }, } ], }); let client = match crate::http_client::blocking_with_timeout(Duration::from_secs(10)) { Ok(c) => c, Err(_) => return, }; let token = myflow_token(repo_root); let mut request = client.post(&api_url).json(&payload); if let Some(value) = token.as_deref() { request = request.bearer_auth(value); } match request.send() { Ok(resp) if resp.status().is_success() => { let finished_at_ms = chrono::Utc::now().timestamp_millis(); let latency_ms = std::cmp::max(0, finished_at_ms - started_at_ms); post_myflow_sync_events( &client, &events_api_url, token.as_deref(), &owner, &repo, &commit_sha, vec![ json!({ "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "transport", "tier": "client", "direction": "outbound", "status": "ok", "latency_ms": latency_ms, "at_ms": finished_at_ms, "details": { "phase": "request_complete", "target": "api/sync", }, }), json!({ "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "persistence_ack", "tier": "server", "direction": "inbound", "status": "ok", "latency_ms": latency_ms, "at_ms": finished_at_ms, "details": { "phase": "sync_ack", "target": "api/sync", }, }), json!({ "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "query_settled", "tier": "client", "direction": "inbound", "status": "ok", "latency_ms": latency_ms, "at_ms": finished_at_ms, "details": { "phase": "ui_visible", "source": "flow-cli", }, }), ], ); if session_count > 0 { println!( "✓ Synced to myflow.sh ({} AI session{})", session_count, if session_count == 1 { "" } else { "s" } ); } else { println!("✓ Synced to myflow.sh"); } } Ok(resp) => { let finished_at_ms = chrono::Utc::now().timestamp_millis(); let latency_ms = std::cmp::max(0, finished_at_ms - started_at_ms); let status_code = resp.status().as_u16(); post_myflow_sync_events( &client, &events_api_url, token.as_deref(), &owner, &repo, &commit_sha, vec![ json!({ "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "transport", "tier": "client", "direction": "outbound", "status": "error", "latency_ms": latency_ms, "error_code": format!("HTTP_{}", status_code), "at_ms": finished_at_ms, "details": { "phase": "request_failed", "target": "api/sync", "status_code": status_code, }, }), json!({ "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "error", "tier": "client", "status": "error", "error_code": format!("HTTP_{}", status_code), "at_ms": finished_at_ms, "details": { "phase": "sync_error", "target": "api/sync", "status_code": status_code, }, }), ], ); debug!("myflow sync failed: HTTP {}", resp.status()); } Err(e) => { let finished_at_ms = chrono::Utc::now().timestamp_millis(); let latency_ms = std::cmp::max(0, finished_at_ms - started_at_ms); post_myflow_sync_events( &client, &events_api_url, token.as_deref(), &owner, &repo, &commit_sha, vec![ json!({ "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "transport", "tier": "client", "direction": "outbound", "status": "error", "latency_ms": latency_ms, "error_code": "NETWORK_ERROR", "at_ms": finished_at_ms, "details": { "phase": "request_exception", "target": "api/sync", "error": e.to_string(), }, }), json!({ "correlation_id": commit_sha, "commit_sha": commit_sha, "event_type": "error", "tier": "client", "status": "error", "error_code": "NETWORK_ERROR", "at_ms": finished_at_ms, "details": { "phase": "sync_error", "target": "api/sync", }, }), ], ); debug!("myflow sync error: {}", e); } } } fn entire_enabled() -> bool { if let Ok(value) = env::var("FLOW_ENTIRE_DISABLE") { let v = value.to_ascii_lowercase(); if v == "1" || v == "true" || v == "yes" { return false; } } let repo_root = git_root_or_cwd(); if !repo_root.join(".entire/settings.json").exists() { return false; } which::which("entire").is_ok() } fn unhash_capture_enabled() -> bool { if let Ok(value) = env::var("UNHASH_DISABLE") { let v = value.to_ascii_lowercase(); if v == "1" || v == "true" || v == "yes" { return false; } } if let Ok(value) = env::var("FLOW_UNHASH") { let v = value.to_ascii_lowercase(); if v == "0" || v == "false" || v == "no" { return false; } } true } fn capture_unhash_bundle( repo_root: &Path, diff: &str, status: Option<&str>, review: Option<&ReviewResult>, review_model: Option<&str>, review_reviewer: Option<&str>, review_instructions: Option<&str>, session_context: Option<&str>, sessions: Option<&[ai::GitEditSessionData]>, gitedit_session_hash: Option<&str>, commit_message: &str, author_message: Option<&str>, include_context: bool, ) -> Option<String> { if !unhash_capture_enabled() { return None; } match try_capture_unhash_bundle( repo_root, diff, status, review, review_model, review_reviewer, review_instructions, session_context, sessions, gitedit_session_hash, commit_message, author_message, include_context, ) { Ok(hash) => hash, Err(err) => { debug!("unhash capture failed: {}", err); None } } } const UNHASH_TRACE_DEFAULT_BYTES: u64 = 64 * 1024; fn default_assistant_trace_roots() -> Vec<PathBuf> { let mut roots = Vec::new(); if let Some(home) = dirs::home_dir() { roots.push( home.join("repos") .join("garden-co") .join("jazz2") .join("assistant-traces"), ); roots.push( home.join("code") .join("org") .join("1f") .join("jazz2") .join("assistant-traces"), ); } roots } fn assistant_traces_root() -> Option<std::path::PathBuf> { if let Ok(value) = env::var("UNHASH_TRACE_DIR") { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(std::path::PathBuf::from(trimmed)); } } default_assistant_trace_roots() .into_iter() .find(|candidate| candidate.exists()) .or_else(|| default_assistant_trace_roots().into_iter().next()) } fn unhash_trace_max_bytes() -> u64 { if let Ok(value) = env::var("UNHASH_TRACE_MAX_BYTES") { if let Ok(parsed) = value.trim().parse::<u64>() { if parsed > 0 { return parsed; } } } UNHASH_TRACE_DEFAULT_BYTES } fn read_tail_bytes(path: &Path, max_bytes: u64) -> Result<Vec<u8>> { let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?; let len = file.metadata()?.len(); if len > max_bytes { let offset = max_bytes.min(len) as i64; file.seek(SeekFrom::End(-offset))?; } let mut buf = Vec::new(); file.read_to_end(&mut buf)?; Ok(buf) } fn write_agent_trace_file(bundle_path: &Path, rel_path: &str, data: &[u8]) -> Result<()> { let target = bundle_path.join(rel_path); if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } fs::write(&target, data)?; Ok(()) } fn write_agent_traces(bundle_path: &Path, repo_root: &Path) { let mut sources = Vec::new(); let max_bytes = unhash_trace_max_bytes(); if max_bytes == 0 { return; } let trace_root = assistant_traces_root(); if let Some(root) = trace_root { let trace_files = [ "ai.jsonl", "linsa.jsonl", "gen.new.jsonl", "last-failure.json", ]; for name in trace_files { let path = root.join(name); if !path.exists() { continue; } match read_tail_bytes(&path, max_bytes) { Ok(data) => { let rel = format!("agent/traces/{}", name); if let Err(err) = write_agent_trace_file(bundle_path, &rel, &data) { debug!("failed to write agent trace {}: {}", rel, err); continue; } sources.push(json!({ "label": name, "path": path.display().to_string(), "bytes": data.len(), })); } Err(err) => debug!("failed to read trace {}: {}", path.display(), err), } } } if let Some(home) = dirs::home_dir() { let cmdlog = home.join(".cmd").join("f").join("index.jsonl"); if cmdlog.exists() { match read_tail_bytes(&cmdlog, max_bytes) { Ok(data) => { let rel = "agent/cmdlog/f.index.jsonl"; if let Err(err) = write_agent_trace_file(bundle_path, rel, &data) { debug!("failed to write {}: {}", rel, err); } else { sources.push(json!({ "label": "cmdlog.f.index", "path": cmdlog.display().to_string(), "bytes": data.len(), })); } } Err(err) => debug!("failed to read cmdlog: {}", err), } } let xdg = env::var("XDG_DATA_HOME") .ok() .filter(|s| !s.trim().is_empty()) .map(std::path::PathBuf::from) .unwrap_or_else(|| home.join(".local").join("share")); let fish_dir = xdg.join("fish").join("io-trace"); let fish_files = [ ("agent/fish/last.stdout", fish_dir.join("last.stdout")), ("agent/fish/last.stderr", fish_dir.join("last.stderr")), ("agent/fish/rise.meta", fish_dir.join("rise.meta")), ( "agent/fish/rise.history.jsonl", fish_dir.join("rise.history.jsonl"), ), ]; for (rel, path) in fish_files { if !path.exists() { continue; } match read_tail_bytes(&path, max_bytes) { Ok(data) => { if let Err(err) = write_agent_trace_file(bundle_path, rel, &data) { debug!("failed to write {}: {}", rel, err); } else { sources.push(json!({ "label": rel, "path": path.display().to_string(), "bytes": data.len(), })); } } Err(err) => debug!("failed to read {}: {}", path.display(), err), } } } if !sources.is_empty() { let index = json!({ "captured_at": chrono::Utc::now().to_rfc3339(), "repo_root": repo_root.to_string_lossy().to_string(), "sources": sources, }); if let Ok(encoded) = serde_json::to_vec_pretty(&index) { let _ = write_agent_trace_file(bundle_path, "agent/trace_index.json", &encoded); } } } fn write_agent_learning( bundle_path: &Path, repo_root: &Path, diff: &str, _status: &str, review: Option<&ReviewResult>, commit_message: &str, sessions_count: usize, ) { let changed_files = changed_files_from_diff(diff); let summary = review .and_then(|r| r.summary.clone()) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| commit_message.to_string()); let issues = review.map(|r| r.issues.clone()).unwrap_or_default(); let future_tasks = review.map(|r| r.future_tasks.clone()).unwrap_or_default(); let root_cause = if !summary.trim().is_empty() { summary.clone() } else if !issues.is_empty() { issues.join("; ") } else { "unknown (see diff)".to_string() }; let prevention = if !future_tasks.is_empty() { future_tasks.join("; ") } else { "Add a regression test or guard for the affected behavior.".to_string() }; let mut tag_texts = Vec::new(); if !summary.is_empty() { tag_texts.push(summary.clone()); } tag_texts.extend(issues.iter().cloned()); let tags = classify_learning_tags(&tag_texts); let learn_json = json!({ "commit": commit_message, "repo": repo_root.file_name().and_then(|n| n.to_str()).unwrap_or("repo"), "repo_root": repo_root.to_string_lossy().to_string(), "issue": issues.first().cloned().unwrap_or_else(|| "none".to_string()), "root_cause": root_cause, "fix": commit_message, "prevention": prevention, "affected_files": changed_files, "tests": [], "tags": tags, "ai_sessions": sessions_count, "review_issues": issues, "review_future_tasks": future_tasks, "created_at": chrono::Utc::now().to_rfc3339(), }); let decision_md = render_learning_decision_md(&learn_json); let regression_md = render_learning_regression_md(&learn_json); let patch_summary_md = render_learning_patch_summary_md(&learn_json); if let Ok(encoded) = serde_json::to_vec_pretty(&learn_json) { let _ = write_agent_trace_file(bundle_path, "agent/learn.json", &encoded); } let _ = write_agent_trace_file(bundle_path, "agent/decision.md", decision_md.as_bytes()); let _ = write_agent_trace_file(bundle_path, "agent/regression.md", regression_md.as_bytes()); let _ = write_agent_trace_file( bundle_path, "agent/patch_summary.md", patch_summary_md.as_bytes(), ); let _ = append_learning_store( repo_root, &learn_json, &decision_md, ®ression_md, &patch_summary_md, ); } fn classify_learning_tags(texts: &[String]) -> Vec<String> { let mut tags = HashSet::new(); for text in texts { let lowered = text.to_lowercase(); if lowered.contains("perf") || lowered.contains("performance") || lowered.contains("latency") { tags.insert("perf".to_string()); } if lowered.contains("security") || lowered.contains("vulnerability") { tags.insert("security".to_string()); } if lowered.contains("panic") || lowered.contains("crash") || lowered.contains("error") || lowered.contains("bug") { tags.insert("bug".to_string()); } if lowered.contains("prompt") || lowered.contains("instruction") { tags.insert("prompt".to_string()); } if lowered.contains("test") || lowered.contains("regression") { tags.insert("test".to_string()); } } let mut out: Vec<String> = tags.into_iter().collect(); out.sort(); out } fn render_learning_decision_md(learn: &serde_json::Value) -> String { let summary = learn .get("root_cause") .and_then(|v| v.as_str()) .unwrap_or("n/a"); let fix = learn.get("fix").and_then(|v| v.as_str()).unwrap_or("n/a"); let prevention = learn .get("prevention") .and_then(|v| v.as_str()) .unwrap_or("n/a"); format!( "# Decision\n\n## Summary\n{}\n\n## Fix\n{}\n\n## Prevention\n{}\n", summary, fix, prevention ) } fn render_learning_regression_md(learn: &serde_json::Value) -> String { let issue = learn .get("issue") .and_then(|v| v.as_str()) .unwrap_or("none"); let prevention = learn .get("prevention") .and_then(|v| v.as_str()) .unwrap_or("n/a"); format!( "# Regression Guard\n\n- If you see: {}\n- Do: {}\n", issue, prevention ) } fn render_learning_patch_summary_md(learn: &serde_json::Value) -> String { let commit = learn.get("fix").and_then(|v| v.as_str()).unwrap_or("n/a"); let files = learn .get("affected_files") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let mut out = String::from("# Patch Summary\n\n"); out.push_str(&format!("- Commit: {}\n", commit)); out.push_str("- Files:\n"); if files.is_empty() { out.push_str(" - (none)\n"); } else { for file in files { if let Some(name) = file.as_str() { out.push_str(&format!(" - {}\n", name)); } } } out } fn append_learning_store( repo_root: &Path, learn_json: &serde_json::Value, decision_md: &str, regression_md: &str, patch_summary_md: &str, ) -> Result<()> { let learn_dir = learning_store_root(repo_root)?; fs::create_dir_all(&learn_dir)?; let learn_jsonl = learn_dir.join("learn.jsonl"); let line = serde_json::to_string(learn_json)? + "\n"; fs::OpenOptions::new() .create(true) .append(true) .open(&learn_jsonl)? .write_all(line.as_bytes())?; let learn_md = learn_dir.join("learn.md"); let mut md = String::new(); md.push_str("\n---\n\n"); md.push_str(decision_md); md.push('\n'); md.push_str(regression_md); md.push('\n'); md.push_str(patch_summary_md); md.push('\n'); fs::OpenOptions::new() .create(true) .append(true) .open(&learn_md)? .write_all(md.as_bytes())?; let _ = append_jazz_learning(&line); Ok(()) } fn learning_store_root(repo_root: &Path) -> Result<PathBuf> { if let Ok(value) = env::var("FLOW_LEARN_DIR") { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(PathBuf::from(trimmed)); } } if let Ok(value) = env::var("FLOW_BASE_DIR") { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(PathBuf::from(trimmed) .join(".ai") .join("internal") .join("learn")); } } if let Some(home) = dirs::home_dir() { return Ok(home .join("code") .join("org") .join("linsa") .join("base") .join(".ai") .join("internal") .join("learn")); } Ok(repo_root.join(".ai").join("internal").join("learn")) } fn append_jazz_learning(line: &str) -> Result<()> { let Some(root) = jazz_assistant_traces_root() else { return Ok(()); }; fs::create_dir_all(&root)?; let path = root.join("base.learn.jsonl"); fs::OpenOptions::new() .create(true) .append(true) .open(&path)? .write_all(line.as_bytes())?; Ok(()) } fn jazz_assistant_traces_root() -> Option<PathBuf> { if let Ok(value) = env::var("FLOW_JAZZ_TRACE_DIR") { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } default_assistant_trace_roots() .into_iter() .find(|candidate| candidate.exists()) .or_else(|| default_assistant_trace_roots().into_iter().next()) } fn try_capture_unhash_bundle( repo_root: &Path, diff: &str, status: Option<&str>, review: Option<&ReviewResult>, review_model: Option<&str>, review_reviewer: Option<&str>, review_instructions: Option<&str>, session_context: Option<&str>, sessions: Option<&[ai::GitEditSessionData]>, gitedit_session_hash: Option<&str>, commit_message: &str, author_message: Option<&str>, include_context: bool, ) -> Result<Option<String>> { let unhash_bin = match which::which("unhash") { Ok(path) => path, Err(_) => { debug!("unhash not found on PATH; skipping commit bundle"); return Ok(None); } }; let mut injected_key: Option<String> = None; if env::var("UNHASH_KEY").is_err() { if let Ok(Some(value)) = flow_env::get_personal_env_var("UNHASH_KEY") { injected_key = Some(value); } else { debug!("UNHASH_KEY not set; skipping commit bundle"); return Ok(None); } } let unhash_dir = repo_root.join(".ai/internal/unhash"); fs::create_dir_all(&unhash_dir) .with_context(|| format!("create unhash dir {}", unhash_dir.display()))?; let bundle_dir: TempDir = TempBuilder::new() .prefix("commit-") .tempdir_in(&unhash_dir) .context("create unhash temp dir")?; let bundle_path = bundle_dir.path(); fs::write(bundle_path.join("diff.patch"), diff).context("write diff.patch")?; let status_value = status .map(|s| s.to_string()) .unwrap_or_else(|| git_capture_in(repo_root, &["status", "--short"]).unwrap_or_default()); fs::write(bundle_path.join("status.txt"), &status_value).context("write status.txt")?; if let Some(context) = session_context { fs::write(bundle_path.join("context.txt"), context).context("write context.txt")?; } let sessions_data: Vec<ai::GitEditSessionData> = match sessions { Some(items) => items.to_vec(), None => ai::get_sessions_for_gitedit(&repo_root.to_path_buf()).unwrap_or_default(), }; if !sessions_data.is_empty() { let json = serde_json::to_string_pretty(&sessions_data).context("serialize sessions.json")?; fs::write(bundle_path.join("sessions.json"), json).context("write sessions.json")?; } write_agent_traces(bundle_path, repo_root); write_agent_learning( bundle_path, repo_root, diff, &status_value, review, commit_message, sessions_data.len(), ); if let Some(review) = review { let review_payload = UnhashReviewPayload { issues_found: review.issues_found, issues: review.issues.clone(), summary: review.summary.clone(), future_tasks: review.future_tasks.clone(), timed_out: review.timed_out, model: review_model.map(|s| s.to_string()), reviewer: review_reviewer.map(|s| s.to_string()), }; let json = serde_json::to_string_pretty(&review_payload).context("serialize review.json")?; fs::write(bundle_path.join("review.json"), json).context("write review.json")?; } let branch = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "unknown".to_string()); let repo_label = match get_gitedit_project(repo_root) { Some((owner, repo)) => format!("{}/{}", owner, repo), None => repo_root .file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "local-repo".to_string()), }; let metadata = UnhashCommitMetadata { repo: repo_label, repo_root: repo_root.to_string_lossy().to_string(), branch: branch.trim().to_string(), created_at: chrono::Utc::now().to_rfc3339(), commit_message: commit_message.to_string(), author_message: author_message.map(|s| s.to_string()), include_context, context_chars: session_context.map(|c| c.len()), review_model: review_model.map(|s| s.to_string()), review_instructions: review_instructions.map(|s| s.to_string()), review_issues: review.map(|r| r.issues.clone()).unwrap_or_default(), review_summary: review.and_then(|r| r.summary.clone()), review_future_tasks: review.map(|r| r.future_tasks.clone()).unwrap_or_default(), review_timed_out: review.map(|r| r.timed_out).unwrap_or(false), gitedit_session_hash: gitedit_session_hash.map(|s| s.to_string()), session_count: sessions_data.len(), }; let meta_json = serde_json::to_string_pretty(&metadata).context("serialize commit.json")?; fs::write(bundle_path.join("commit.json"), meta_json).context("write commit.json")?; let out_file = TempBuilder::new() .prefix("bundle-") .suffix(".uhx") .tempfile_in(&unhash_dir) .context("create temp bundle file")?; let out_path = out_file.path().to_path_buf(); drop(out_file); let mut cmd = Command::new(unhash_bin); cmd.arg(bundle_path).arg("--out").arg(&out_path); cmd.current_dir(repo_root); if let Some(value) = injected_key { cmd.env("UNHASH_KEY", value); } let output = cmd.output().context("run unhash")?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); debug!("unhash failed: {} {}{}", output.status, stdout, stderr); return Ok(None); } let stdout = String::from_utf8_lossy(&output.stdout); let mut hash = String::new(); for line in stdout.lines() { let trimmed = line.trim(); if !trimmed.is_empty() { hash = trimmed.to_string(); break; } } if hash.is_empty() { debug!("unhash output missing hash"); return Ok(None); } let final_path = unhash_dir.join(format!("{}.uhx", hash)); if final_path != out_path { if let Err(err) = fs::rename(&out_path, &final_path) { debug!("failed to move unhash bundle: {}", err); } } Ok(Some(hash)) } fn stage_changes_for_commit(workdir: &Path, stage_paths: &[String]) -> Result<()> { print!("Staging changes... "); io::stdout().flush()?; if stage_paths.is_empty() { git_run_in(workdir, &["add", "."])?; println!("done"); return Ok(()); } git_run_in(workdir, &["reset", "--quiet"])?; let mut cmd = Command::new("git"); let status = cmd .current_dir(workdir) .arg("add") .arg("--") .args(stage_paths) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run git add for selected paths")?; if !status.success() { bail!("git add -- <paths> failed with status {}", status); } println!( "done ({} path{})", stage_paths.len(), if stage_paths.len() == 1 { "" } else { "s" } ); Ok(()) } fn split_paragraphs(message: &str) -> Vec<String> { let mut paragraphs = Vec::new(); let mut current = Vec::new(); for line in message.lines() { if line.trim().is_empty() { if !current.is_empty() { paragraphs.push(current.join("\n")); current.clear(); } } else { current.push(line.trim_end()); } } if !current.is_empty() { paragraphs.push(current.join("\n")); } paragraphs } fn stage_paths_cli_flags(stage_paths: &[String]) -> String { let mut flags = String::new(); for path in stage_paths { flags.push_str(&format!(" --path {:?}", path)); } flags } fn delegate_to_hub( push: bool, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], ) -> Result<()> { let repo_root = git_root_or_cwd(); warn_if_commit_invoked_from_subdir(&repo_root); // Build the command to run using the current executable path let push_flag = if push { "" } else { " --no-push" }; let queue_flag = queue_flag_for_command(queue); let review_flag = review_flag_for_command(queue); let hashed_flag = if include_unhash { " --hashed" } else { "" }; let path_flags = stage_paths_cli_flags(stage_paths); let flow_bin = std::env::current_exe() .ok() .map(|p| p.display().to_string()) .unwrap_or_else(|| "flow".to_string()); let command = format!( "{} commit --sync{}{}{}{}{}", flow_bin, push_flag, queue_flag, review_flag, hashed_flag, path_flags ); let url = format!("http://{}:{}/tasks/run", HUB_HOST, HUB_PORT); let client = crate::http_client::blocking_with_timeout(Duration::from_secs(5)) .context("failed to create HTTP client")?; let payload = json!({ "task": { "name": "commit", "command": command, "dependencies": { "commands": [], "flox": [], }, }, "cwd": repo_root.to_string_lossy(), "flow_version": env!("CARGO_PKG_VERSION"), }); let resp = client .post(&url) .json(&payload) .send() .context("failed to submit commit to hub")?; if resp.status().is_success() { // Parse response to get task_id let body: serde_json::Value = resp.json().unwrap_or_default(); if let Some(task_id) = body.get("task_id").and_then(|v| v.as_str()) { println!("Delegated commit to hub"); println!(" View logs: f logs --task-id {}", task_id); println!(" Stream logs: f logs --task-id {} --follow", task_id); } else { println!("Delegated commit to hub"); } Ok(()) } else { let body = resp.text().unwrap_or_default(); bail!("hub returned error: {}", body); } } fn delegate_to_hub_with_check( command_name: &str, push: bool, include_context: bool, review_selection: ReviewSelection, author_message: Option<&str>, max_tokens: usize, queue: CommitQueueMode, include_unhash: bool, stage_paths: &[String], gate_overrides: CommitGateOverrides, ) -> Result<()> { let repo_root = resolve_commit_with_check_root()?; warn_if_commit_invoked_from_subdir(&repo_root); // Generate early gitedit hash from session IDs + owner/repo let early_gitedit_url = generate_early_gitedit_url(&repo_root); // Build the command to run using the current executable path let push_flag = if push { "" } else { " --no-push" }; let queue_flag = queue_flag_for_command(queue); let review_flag = review_flag_for_command(queue); let context_flag = if include_context { " --context" } else { "" }; let codex_flag = if review_selection.is_codex() { " --codex" } else { "" }; let message_flag = author_message .map(|m| format!(" --message {:?}", m)) .unwrap_or_default(); let review_model_flag = review_selection .review_model_arg() .map(|arg| format!(" --review-model {}", arg.as_arg())) .unwrap_or_default(); let hashed_flag = if include_unhash { " --hashed" } else { "" }; let skip_quality_flag = if gate_overrides.skip_quality { " --skip-quality" } else { "" }; let skip_docs_flag = if gate_overrides.skip_docs { " --skip-docs" } else { "" }; let skip_tests_flag = if gate_overrides.skip_tests { " --skip-tests" } else { "" }; let path_flags = stage_paths_cli_flags(stage_paths); let flow_bin = std::env::current_exe() .ok() .map(|p| p.display().to_string()) .unwrap_or_else(|| "flow".to_string()); let command = format!( "{} {} --sync{}{}{}{}{}{}{}{}{}{}{}{} --tokens {}", flow_bin, command_name, push_flag, context_flag, codex_flag, review_model_flag, message_flag, queue_flag, review_flag, hashed_flag, skip_quality_flag, skip_docs_flag, skip_tests_flag, path_flags, max_tokens ); let url = format!("http://{}:{}/tasks/run", HUB_HOST, HUB_PORT); let client = crate::http_client::blocking_with_timeout(Duration::from_secs(5)) .context("failed to create HTTP client")?; let payload = json!({ "task": { "name": command_name, "command": command, "dependencies": { "commands": [], "flox": [], }, }, "cwd": repo_root.to_string_lossy(), "flow_version": env!("CARGO_PKG_VERSION"), }); let resp = client .post(&url) .json(&payload) .send() .context("failed to submit commitWithCheck to hub")?; if resp.status().is_success() { // Parse response to get task_id let body: serde_json::Value = resp.json().unwrap_or_default(); if let Some(task_id) = body.get("task_id").and_then(|v| v.as_str()) { println!("Delegated {} to hub", command_name); println!(" View logs: f logs --task-id {}", task_id); println!(" Stream logs: f logs --task-id {} --follow", task_id); if let Some(gitedit_url) = early_gitedit_url { println!(" GitEdit: {}", gitedit_url); } } else { println!("Delegated {} to hub", command_name); } Ok(()) } else { let body = resp.text().unwrap_or_default(); bail!("hub returned error: {}", body); } } /// Generate gitedit URL early from session IDs (before full data load). fn generate_early_gitedit_url(repo_root: &std::path::Path) -> Option<String> { // Check if gitedit is globally enabled if !gitedit_globally_enabled() { return None; } // Get owner/repo let (owner, repo) = get_gitedit_project(repo_root)?; // Get session IDs and checkpoint for hashing let (session_ids, checkpoint_ts) = ai::get_session_ids_for_hash(&repo_root.to_path_buf()).ok()?; if session_ids.is_empty() { return None; } // Generate hash from owner/repo + session IDs + checkpoint let mut hasher = DefaultHasher::new(); owner.hash(&mut hasher); repo.hash(&mut hasher); for sid in &session_ids { sid.hash(&mut hasher); } if let Some(ts) = &checkpoint_ts { ts.hash(&mut hasher); } let hash = format!("{:016x}", hasher.finish()); let base_url = gitedit_api_url(repo_root); let base_url = base_url.trim_end_matches('/'); Some(format!("{}/{}", base_url, hash)) } // ───────────────────────────────────────────────────────────── // Pre-commit fixers // ───────────────────────────────────────────────────────────── /// Run pre-commit fixers from [commit] config. pub fn run_fixers(repo_root: &Path) -> Result<bool> { let config_path = repo_root.join("flow.toml"); let config = if config_path.exists() { config::load(&config_path)? } else { return Ok(false); }; let commit_cfg = match &config.commit { Some(c) if !c.fixers.is_empty() => c, _ => return Ok(false), }; let mut any_fixed = false; for fixer in &commit_cfg.fixers { match run_fixer(repo_root, fixer) { Ok(fixed) => { if fixed { any_fixed = true; } } Err(e) => { eprintln!("Fixer '{}' failed: {}", fixer, e); } } } Ok(any_fixed) } /// Run a single fixer. Returns true if any files were modified. fn run_fixer(repo_root: &Path, fixer: &str) -> Result<bool> { // Custom command: "cmd:prettier --write" if let Some(cmd) = fixer.strip_prefix("cmd:") { return run_action_script(repo_root, cmd); } // Check for script in .ai/actions/ let action_path = repo_root.join(".ai/actions").join(fixer); if action_path.exists() { return run_action_script(repo_root, action_path.to_str().unwrap_or(fixer)); } // Fallback to built-in fixers match fixer { "mdx-comments" => fix_mdx_comments(repo_root), "trailing-whitespace" => fix_trailing_whitespace(repo_root), "end-of-file" => fix_end_of_file(repo_root), "lowercase-filenames" => fix_lowercase_filenames(repo_root), _ => { debug!("Unknown fixer and no .ai/actions/{} script found", fixer); Ok(false) } } } /// Run an action script from .ai/actions/ or a custom command. fn run_action_script(repo_root: &Path, cmd: &str) -> Result<bool> { let display_name = cmd.strip_prefix(".ai/actions/").unwrap_or(cmd); println!("Running: {}", display_name); let status = Command::new("sh") .arg("-c") .arg(cmd) .current_dir(repo_root) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status()?; Ok(status.success()) } /// Fix MDX comments: convert <!-- --> to {/* */} fn fix_mdx_comments(repo_root: &Path) -> Result<bool> { // Quick check: any HTML comments in MDX files? let check = Command::new("git") .args(["grep", "-l", "<!--", "--", "*.mdx", "**/*.mdx"]) .current_dir(repo_root) .output()?; let files_with_issues: Vec<_> = String::from_utf8_lossy(&check.stdout) .lines() .filter(|l| !l.is_empty()) .map(|l| repo_root.join(l)) .collect(); if files_with_issues.is_empty() { return Ok(false); } let mut fixed_any = false; for file in files_with_issues { if let Ok(content) = fs::read_to_string(&file) { let fixed = fix_html_comments_to_jsx(&content); if fixed != content { fs::write(&file, &fixed)?; println!(" Fixed MDX comments: {}", file.display()); fixed_any = true; } } } if fixed_any { println!("✓ Fixed MDX comments"); } Ok(fixed_any) } /// Convert HTML comments to JSX comments in MDX content. fn fix_html_comments_to_jsx(content: &str) -> String { let mut result = String::with_capacity(content.len()); let mut chars = content.chars().peekable(); while let Some(c) = chars.next() { if c == '<' && chars.peek() == Some(&'!') { // Potential HTML comment let mut buf = String::from("<"); buf.push(chars.next().unwrap()); // ! // Check for -- if chars.peek() == Some(&'-') { buf.push(chars.next().unwrap()); // first - if chars.peek() == Some(&'-') { buf.push(chars.next().unwrap()); // second - // Found <!--, now collect until --> let mut comment_content = String::new(); loop { match chars.next() { Some('-') => { if chars.peek() == Some(&'-') { chars.next(); // consume second - if chars.peek() == Some(&'>') { chars.next(); // consume > // Found -->, convert to JSX comment result.push_str("{/* "); result.push_str(comment_content.trim()); result.push_str(" */}"); break; } else { comment_content.push_str("--"); } } else { comment_content.push('-'); } } Some(ch) => comment_content.push(ch), None => { // Unclosed comment, keep original result.push_str(&buf); result.push_str(&comment_content); break; } } } continue; } } result.push_str(&buf); } else { result.push(c); } } result } /// Fix trailing whitespace in text files. fn fix_trailing_whitespace(repo_root: &Path) -> Result<bool> { // Quick check: any trailing whitespace in working directory changes? let check = Command::new("git") .args(["diff", "--check"]) .current_dir(repo_root) .output()?; // --check exits non-zero and outputs lines if there's trailing whitespace if check.stdout.is_empty() { return Ok(false); } let mut fixed_any = false; // Get modified/new text files (unstaged) let output = Command::new("git") .args(["diff", "--name-only", "--diff-filter=ACMR"]) .current_dir(repo_root) .output()?; let files: Vec<_> = String::from_utf8_lossy(&output.stdout) .lines() .filter(|l| !l.is_empty()) .map(|l| repo_root.join(l)) .collect(); for file in files { if !file.exists() || is_binary(&file) { continue; } if let Ok(content) = fs::read_to_string(&file) { let fixed: String = content .lines() .map(|line| line.trim_end()) .collect::<Vec<_>>() .join("\n"); // Preserve original line ending let fixed = if content.ends_with('\n') && !fixed.ends_with('\n') { format!("{}\n", fixed) } else { fixed }; if fixed != content { fs::write(&file, &fixed)?; println!(" Trimmed whitespace: {}", file.display()); fixed_any = true; } } } if fixed_any { println!("✓ Fixed trailing whitespace"); } Ok(fixed_any) } /// Ensure files end with a newline. fn fix_end_of_file(repo_root: &Path) -> Result<bool> { // Quick check: any files missing final newline in working directory? let check = Command::new("git") .args(["diff"]) .current_dir(repo_root) .output()?; let diff_output = String::from_utf8_lossy(&check.stdout); if !diff_output.contains("\\ No newline at end of file") { return Ok(false); } let mut fixed_any = false; let output = Command::new("git") .args(["diff", "--name-only", "--diff-filter=ACMR"]) .current_dir(repo_root) .output()?; let files: Vec<_> = String::from_utf8_lossy(&output.stdout) .lines() .filter(|l| !l.is_empty()) .map(|l| repo_root.join(l)) .collect(); for file in files { if !file.exists() || is_binary(&file) { continue; } if let Ok(content) = fs::read_to_string(&file) { if !content.is_empty() && !content.ends_with('\n') { fs::write(&file, format!("{}\n", content))?; println!(" Added newline: {}", file.display()); fixed_any = true; } } } if fixed_any { println!("✓ Fixed end of file newlines"); } Ok(fixed_any) } /// Rename staged files with uppercase basenames to lowercase. fn fix_lowercase_filenames(repo_root: &Path) -> Result<bool> { // Get staged new/renamed files let output = Command::new("git") .args(["diff", "--cached", "--name-only", "--diff-filter=ACR"]) .current_dir(repo_root) .output()?; let files: Vec<String> = String::from_utf8_lossy(&output.stdout) .lines() .filter(|l| !l.is_empty()) .map(|l| l.to_string()) .collect(); let mut fixed_any = false; for file in &files { let path = Path::new(file); let basename = match path.file_name().and_then(|n| n.to_str()) { Some(n) => n, None => continue, }; if !basename.chars().any(|c| c.is_ascii_uppercase()) { continue; } let lower = basename.to_ascii_lowercase(); let new_path = match path.parent() { Some(p) if p != Path::new("") => p.join(&lower), _ => PathBuf::from(&lower), }; let status = Command::new("git") .args(["mv", file, new_path.to_str().unwrap_or(&lower)]) .current_dir(repo_root) .output()?; if status.status.success() { println!(" Renamed: {} → {}", file, new_path.display()); fixed_any = true; } } if fixed_any { println!("✓ Fixed uppercase filenames"); } Ok(fixed_any) } /// Simple binary file detection. fn is_binary(path: &Path) -> bool { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); matches!( ext, "png" | "jpg" | "jpeg" | "gif" | "ico" | "webp" | "svg" | "woff" | "woff2" | "ttf" | "otf" | "eot" | "zip" | "tar" | "gz" | "rar" | "7z" | "pdf" | "doc" | "docx" | "xls" | "xlsx" | "exe" | "dll" | "so" | "dylib" | "mp3" | "mp4" | "wav" | "avi" | "mov" ) } /// Get review instructions from [commit] config or .ai/ folder. pub fn get_review_instructions(repo_root: &Path) -> Option<String> { // Check config first let config_path = repo_root.join("flow.toml"); if let Ok(config) = config::load(&config_path) { if let Some(commit_cfg) = config.commit.as_ref() { // Try inline instructions if let Some(instructions) = &commit_cfg.review_instructions { return Some(instructions.clone()); } // Try loading from configured file if let Some(file_path) = &commit_cfg.review_instructions_file { let full_path = repo_root.join(file_path); if let Ok(content) = fs::read_to_string(full_path) { return Some(content); } } } } // Auto-discover from .ai/ folder (no config needed) let candidates = [ ".ai/review.md", ".ai/commit-review.md", ".ai/instructions.md", ]; for candidate in candidates { let path = repo_root.join(candidate); if let Ok(content) = fs::read_to_string(&path) { return Some(content); } } None } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn ai_scratch_tests_are_excluded_from_related_tests() { let repo_root = Path::new("."); let changed = vec![ ".ai/test/generated/auth-flow.test.ts".to_string(), "mobile/src/pages/chats/home/ui/ChatsList.test.tsx".to_string(), ]; let related = find_related_tests(repo_root, &changed, ".ai/test"); assert_eq!( related, vec!["mobile/src/pages/chats/home/ui/ChatsList.test.tsx".to_string()] ); } #[test] fn path_within_dir_handles_relative_prefixes() { assert!(path_is_within_dir("./.ai/test/foo.test.ts", ".ai/test")); assert!(path_is_within_dir(".ai/test", ".ai/test")); assert!(!path_is_within_dir("mobile/src/foo.test.ts", ".ai/test")); } #[test] fn commit_message_selection_parsing_supports_fallback_specs() { assert!(matches!( parse_commit_message_selection_spec("remote"), Some(CommitMessageSelection::Remote) )); assert!(matches!( parse_commit_message_selection_spec("openai"), Some(CommitMessageSelection::OpenAi) )); assert!(matches!( parse_commit_message_selection_spec("heuristic"), Some(CommitMessageSelection::Heuristic) )); match parse_commit_message_selection_spec("openrouter:moonshotai/kimi-k2") { Some(CommitMessageSelection::OpenRouter { model }) => { assert_eq!(model, "moonshotai/kimi-k2") } _ => panic!("expected openrouter message selection"), } match parse_commit_message_selection_with_model( "rise", Some("zai:glm-4.7-thinking".to_string()), ) { Some(CommitMessageSelection::Rise { model }) => { assert_eq!(model, "zai:glm-4.7-thinking") } _ => panic!("expected rise message selection"), } } #[test] fn deterministic_commit_message_includes_changed_files() { let diff = format!( "{} b/src/lib.rs\n+added\n{} b/src/main.rs\n+added", "+++", "+++" ); let message = build_deterministic_commit_message(&diff); assert!(message.starts_with("Update 2 files")); assert!(message.contains("- src/lib.rs")); assert!(message.contains("- src/main.rs")); } #[test] fn glm5_alias_maps_to_rise_selection() { match parse_review_selection_spec("glm5") { Some(ReviewSelection::Rise { model }) => assert_eq!(model, DEFAULT_GLM5_RISE_MODEL), _ => panic!("expected glm5 to map to rise review selection"), } match parse_commit_message_selection_spec("glm5") { Some(CommitMessageSelection::Rise { model }) => { assert_eq!(model, DEFAULT_GLM5_RISE_MODEL) } _ => panic!("expected glm5 to map to rise commit message selection"), } } #[test] fn normalize_markdown_linebreaks_decodes_literal_newlines() { let input = "## Summary\\n- one\\n- two\\n\\n## Why\\n- because"; let out = normalize_markdown_linebreaks(input); assert!(out.contains("## Summary\n- one\n- two")); assert!(out.contains("\n\n## Why\n- because")); } #[test] fn normalize_markdown_linebreaks_preserves_existing_multiline_text() { let input = "## Summary\n- already\n- multiline"; let out = normalize_markdown_linebreaks(input); assert_eq!(out, input); } #[test] fn normalize_codex_bin_value_expands_tilde_paths() { let expected = config::expand_path("~/code/flow/scripts/codex-flow-wrapper") .to_string_lossy() .into_owned(); assert_eq!( normalize_codex_bin_value("~/code/flow/scripts/codex-flow-wrapper"), expected ); } #[test] fn configured_codex_bin_for_workdir_uses_expanded_global_path() { let global_cfg = config::default_config_path(); let backup = fs::read_to_string(&global_cfg).ok(); let root = global_cfg .parent() .expect("global config dir") .to_path_buf(); fs::create_dir_all(&root).expect("create global config dir"); fs::write( &global_cfg, "[options]\ncodex_bin = \"~/code/flow/scripts/codex-flow-wrapper\"\n", ) .expect("write global codex config"); let temp = tempdir().expect("tempdir"); let resolved = configured_codex_bin_for_workdir(temp.path()); let expected = config::expand_path("~/code/flow/scripts/codex-flow-wrapper") .to_string_lossy() .into_owned(); assert_eq!(resolved, expected); match backup { Some(content) => fs::write(&global_cfg, content).expect("restore global config"), None => { let _ = fs::remove_file(&global_cfg); } } } #[test] fn invariants_dep_check_flags_unapproved_dependencies() { let package_json = r#"{ "dependencies": { "react": "^18.0.0", "@reatom/core": "^3.0.0" }, "devDependencies": { "vitest": "^1.0.0" } }"#; let approved = vec!["@reatom/core".to_string(), "vitest".to_string()]; let mut findings = Vec::new(); check_unapproved_deps(package_json, &approved, "package.json", &mut findings); assert_eq!(findings.len(), 1); assert_eq!(findings[0].category, "deps"); assert!(findings[0].message.contains("react")); assert_eq!(findings[0].file.as_deref(), Some("package.json")); } #[test] fn invariant_prompt_context_includes_rules_and_findings() { let mut terminology = HashMap::new(); terminology.insert("Flow".to_string(), "CLI tool".to_string()); let inv = config::InvariantsConfig { architecture_style: Some("event-driven".to_string()), non_negotiable: vec!["no inline imports".to_string()], terminology, ..Default::default() }; let report = InvariantGateReport { findings: vec![InvariantFinding { severity: "warning".to_string(), category: "forbidden".to_string(), message: "Forbidden pattern 'useState(' in added line".to_string(), file: Some("web/app.tsx".to_string()), }], }; let ctx = report.to_prompt_context(&inv); assert!(ctx.contains("Project Invariants")); assert!(ctx.contains("Architecture: event-driven")); assert!(ctx.contains("no inline imports")); assert!(ctx.contains("Flow: CLI tool")); assert!(ctx.contains("web/app.tsx")); assert!(ctx.contains("Forbidden pattern")); } #[test] fn parse_pr_feedback_args_accepts_full_flag() { let parsed = parse_pr_feedback_args(&[ "feedback".to_string(), "2922".to_string(), "--full".to_string(), ]) .expect("parse") .expect("command"); assert_eq!(parsed.selector.as_deref(), Some("2922")); assert!(parsed.show_full); assert!(!parsed.record_todos); assert!(!parsed.open_cursor); } #[test] fn parse_pr_feedback_args_defaults_to_full_output() { let parsed = parse_pr_feedback_args(&["feedback".to_string(), "2922".to_string()]) .expect("parse") .expect("command"); assert_eq!(parsed.selector.as_deref(), Some("2922")); assert!(parsed.show_full); assert!(!parsed.open_cursor); } #[test] fn parse_pr_feedback_args_accepts_compact_flag() { let parsed = parse_pr_feedback_args(&[ "feedback".to_string(), "2922".to_string(), "--compact".to_string(), ]) .expect("parse") .expect("command"); assert_eq!(parsed.selector.as_deref(), Some("2922")); assert!(!parsed.show_full); assert!(!parsed.open_cursor); } #[test] fn parse_pr_feedback_args_accepts_cursor_flag() { let parsed = parse_pr_feedback_args(&[ "feedback".to_string(), "2922".to_string(), "--cursor".to_string(), ]) .expect("parse") .expect("command"); assert_eq!(parsed.selector.as_deref(), Some("2922")); assert!(parsed.open_cursor); } #[test] fn write_pr_feedback_review_plan_includes_snapshot_and_kit_input() { let temp = tempdir().expect("tempdir"); let snapshot_path = temp.path().join(".ai/reviews/pr-feedback-2922.md"); let json_path = temp.path().join(".ai/reviews/pr-feedback-2922.json"); fs::create_dir_all(snapshot_path.parent().expect("snapshot parent")).expect("mkdirs"); fs::write(&snapshot_path, "# snapshot\n").expect("write snapshot"); fs::write(&json_path, "{}\n").expect("write json snapshot"); let snapshot = PrFeedbackSnapshot { repo: "fl2024008/prometheus".to_string(), pr_number: 2922, pr_url: "https://github.com/fl2024008/prometheus/pull/2922".to_string(), pr_title: "feat(designer): add build123d Python live viewer".to_string(), trace_id: "trace-2922".to_string(), generated_at: "2026-03-17T15:00:00Z".to_string(), reviews_count: 1, review_comments_count: 1, issue_comments_count: 0, review_state_counts: HashMap::from([("CHANGES_REQUESTED".to_string(), 1usize)]), items: vec![PrFeedbackItem { external_ref: "ref".to_string(), source: "review-comment", author: "reviewer".to_string(), body: "Please move this logic.".to_string(), url: "https://github.com/example".to_string(), thread_id: None, path: Some("src/file.ts".to_string()), line: Some(42), review_state: None, diff_hunk: Some("@@ -1,2 +1,2 @@\n-old\n+new".to_string()), }], }; let plan_root = temp.path().join("review-plans"); fs::create_dir_all(&plan_root).expect("plan root"); let plan_path = write_pr_feedback_review_plan_at( &plan_root, temp.path(), &snapshot, &snapshot_path, &json_path, ) .expect("write plan"); let body = fs::read_to_string(&plan_path).expect("read plan"); assert!(body.contains("# [feat(designer): add build123d Python live viewer](https://github.com/fl2024008/prometheus/pull/2922)")); assert!(body.contains("## Cursor Review")); assert!(body.contains("Snapshot (markdown):")); assert!(body.contains("Trace ID: `trace-2922`")); assert!(body.contains("## Kit Commands")); assert!(body.contains("--feedback-auto --preset designer")); assert!(body.contains( "f pr feedback https://github.com/fl2024008/prometheus/pull/2922 --compact --cursor" )); assert!(body.contains("### Diff Hunk")); assert!(body.contains("### Concern Status")); assert!(body.contains("The reviewer is asking for intent and ownership")); assert!(body.contains("The diff likely moved or extracted code to clean up structure")); assert!(body.contains("Make the smallest placement or naming change in file.ts:42")); assert!(body.contains("Open the affected the product flow")); assert!(body.contains("when moving logic across component or module boundaries")); assert!(body.contains("### Kit Upgrade")); assert!(body.contains("### Status")); assert!(body.contains("## Kit Input")); assert!(plan_path.ends_with("fl2024008-prometheus-pr-2922-feedback.md")); } #[test] fn write_pr_feedback_review_rules_mentions_artifacts() { let temp = tempdir().expect("tempdir"); let snapshot_path = temp.path().join(".ai/reviews/pr-feedback-2922.md"); let json_path = temp.path().join(".ai/reviews/pr-feedback-2922.json"); let review_plan_path = temp .path() .join("review/fl2024008-prometheus-pr-2922-feedback.md"); let kit_system_path = temp .path() .join("review/fl2024008-prometheus-pr-2922-kit-system.md"); fs::create_dir_all(snapshot_path.parent().expect("snapshot parent")).expect("mkdirs"); fs::create_dir_all(review_plan_path.parent().expect("review plan parent")).expect("mkdirs"); fs::write(&snapshot_path, "# snapshot\n").expect("write snapshot"); fs::write(&json_path, "{}\n").expect("write json snapshot"); fs::write(&review_plan_path, "# plan\n").expect("write review plan"); fs::write(&kit_system_path, "# kit\n").expect("write kit prompt"); let snapshot = PrFeedbackSnapshot { repo: "fl2024008/prometheus".to_string(), pr_number: 2922, pr_url: "https://github.com/fl2024008/prometheus/pull/2922".to_string(), pr_title: "feat(designer): add build123d Python live viewer".to_string(), trace_id: "trace-2922".to_string(), generated_at: "2026-03-17T15:00:00Z".to_string(), reviews_count: 1, review_comments_count: 1, issue_comments_count: 0, review_state_counts: HashMap::from([("CHANGES_REQUESTED".to_string(), 1usize)]), items: vec![], }; let plan_root = temp.path().join("review-plans"); fs::create_dir_all(&plan_root).expect("plan root"); let review_rules_path = write_pr_feedback_review_rules_at( &plan_root, temp.path(), &snapshot, &snapshot_path, &json_path, &review_plan_path, &kit_system_path, ) .expect("write review rules"); let body = fs::read_to_string(&review_rules_path).expect("read review rules"); assert!( body.contains("Generated operator artifact for resolving PR feedback item by item") ); assert!(body.contains(&snapshot_path.display().to_string())); assert!(body.contains(&json_path.display().to_string())); assert!(body.contains(&review_plan_path.display().to_string())); assert!(body.contains(&kit_system_path.display().to_string())); assert!(body.contains("## One-Item Loop")); assert!(body.contains("## Prompt Template")); assert!(body.contains("Decide the Concern Status first")); assert!(body.contains("- Concern Status")); assert!(body.contains("- `Concern Status`")); assert!(review_rules_path.ends_with("fl2024008-prometheus-pr-2922-review-rules.md")); } #[test] fn write_pr_feedback_kit_system_prompt_mentions_artifacts() { let temp = tempdir().expect("tempdir"); let snapshot_path = temp.path().join(".ai/reviews/pr-feedback-2922.md"); let json_path = temp.path().join(".ai/reviews/pr-feedback-2922.json"); let review_plan_path = temp .path() .join("review/fl2024008-prometheus-pr-2922-feedback.md"); fs::create_dir_all(snapshot_path.parent().expect("snapshot parent")).expect("mkdirs"); fs::create_dir_all(review_plan_path.parent().expect("review plan parent")).expect("mkdirs"); fs::write(&snapshot_path, "# snapshot\n").expect("write snapshot"); fs::write(&json_path, "{}\n").expect("write json snapshot"); fs::write(&review_plan_path, "# plan\n").expect("write review plan"); let snapshot = PrFeedbackSnapshot { repo: "fl2024008/prometheus".to_string(), pr_number: 2922, pr_url: "https://github.com/fl2024008/prometheus/pull/2922".to_string(), pr_title: "feat(designer): add build123d Python live viewer".to_string(), trace_id: "trace-2922".to_string(), generated_at: "2026-03-17T15:00:00Z".to_string(), reviews_count: 1, review_comments_count: 1, issue_comments_count: 0, review_state_counts: HashMap::from([("CHANGES_REQUESTED".to_string(), 1usize)]), items: vec![], }; let plan_root = temp.path().join("review-plans"); fs::create_dir_all(&plan_root).expect("plan root"); let kit_system_path = write_pr_feedback_kit_system_prompt_at( &plan_root, &snapshot, &snapshot_path, &json_path, &review_plan_path, ) .expect("write kit prompt"); let body = fs::read_to_string(&kit_system_path).expect("read kit prompt"); assert!(body.contains("Kit PR Feedback Prevention System Prompt")); assert!(body.contains(&snapshot_path.display().to_string())); assert!(body.contains(&json_path.display().to_string())); assert!(body.contains(&review_plan_path.display().to_string())); assert!(kit_system_path.ends_with("fl2024008-prometheus-pr-2922-kit-system.md")); } } ================================================ FILE: src/commits.rs ================================================ //! Browse and analyze git commits with AI session metadata. //! //! Shows commits with attached AI sessions, reviews, and other metadata. use std::collections::HashSet; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use crate::cli::{CommitsAction, CommitsCommand, CommitsOpts}; use crate::vcs; const TOP_COMMITS_PATH: &str = ".ai/internal/commits/top.txt"; /// Commit with associated metadata #[derive(Debug, Clone)] struct CommitEntry { /// Git commit hash (short) hash: String, /// Full commit hash full_hash: String, /// Commit subject line (used in display) #[allow(dead_code)] subject: String, /// Relative time (e.g., "2 hours ago") relative_time: String, /// Author name author: String, /// Whether this commit has AI session metadata has_ai_metadata: bool, /// Whether this commit is marked notable is_top: bool, /// Display string for fzf display: String, } /// Run the commits subcommand. pub fn run(cmd: CommitsCommand) -> Result<()> { match cmd.action { Some(CommitsAction::Top) => run_top(), Some(CommitsAction::Mark { hash }) => mark_top_commit(&hash), Some(CommitsAction::Unmark { hash }) => unmark_top_commit(&hash), None => run_list(&cmd.opts), } } fn run_list(opts: &CommitsOpts) -> Result<()> { let _ = vcs::ensure_jj_repo()?; let top_entries = load_top_entries()?; let top_set = top_hashes(&top_entries); let commits = list_commits(opts.limit, opts.all, &top_set)?; if commits.is_empty() { println!("No commits found."); return Ok(()); } // Check for fzf if which::which("fzf").is_err() { println!("fzf not found – install it for fuzzy selection."); println!("\nCommits:"); for commit in &commits { println!("{}", commit.display); } return Ok(()); } // Run fzf with preview println!("Tip: press ctrl-t to toggle notable for the selected commit."); if let Some(selection) = run_commits_fzf(&commits)? { match selection.action { CommitAction::Show => show_commit_details(selection.entry)?, CommitAction::ToggleTop => toggle_top_commit(selection.entry)?, } } Ok(()) } fn run_top() -> Result<()> { let top_entries = load_top_entries()?; if top_entries.is_empty() { println!("No notable commits yet."); return Ok(()); } let commits = list_commits_by_hashes(&top_entries)?; if commits.is_empty() { println!("No notable commits found."); return Ok(()); } if which::which("fzf").is_err() { println!("fzf not found – install it for fuzzy selection."); println!("\nNotable commits:"); for commit in &commits { println!("{}", commit.display); } return Ok(()); } println!("Tip: press ctrl-t to toggle notable for the selected commit."); if let Some(selection) = run_commits_fzf(&commits)? { match selection.action { CommitAction::Show => show_commit_details(selection.entry)?, CommitAction::ToggleTop => toggle_top_commit(selection.entry)?, } } Ok(()) } fn mark_top_commit(hash: &str) -> Result<()> { let commit = load_commit_by_ref(hash)?.context("Commit not found")?; let mut entries = load_top_entries()?; if entries.iter().any(|entry| entry.hash == commit.full_hash) { println!("Commit already marked notable: {}", commit.hash); return Ok(()); } let label = commit.subject.replace('\t', " "); entries.push(TopEntry { hash: commit.full_hash.clone(), label: Some(label), }); write_top_entries(&entries)?; println!("Marked notable: {} {}", commit.hash, commit.subject); Ok(()) } fn unmark_top_commit(hash: &str) -> Result<()> { let full_hash = resolve_full_hash(hash)?; let mut entries = load_top_entries()?; let before = entries.len(); entries.retain(|entry| entry.hash != full_hash); if entries.len() == before { println!("Commit not in notable list: {}", hash); return Ok(()); } write_top_entries(&entries)?; println!("Removed notable commit: {}", hash); Ok(()) } /// List recent commits with metadata. fn list_commits( limit: usize, all_branches: bool, top_hashes: &HashSet<String>, ) -> Result<Vec<CommitEntry>> { let mut args = vec!["log", "--pretty=format:%h|%H|%s|%ar|%an", "-n"]; let limit_str = limit.to_string(); args.push(&limit_str); if all_branches { args.push("--all"); } let output = Command::new("git") .args(&args) .output() .context("failed to run git log")?; if !output.status.success() { bail!("git log failed"); } let stdout = String::from_utf8_lossy(&output.stdout); let mut commits = Vec::new(); for line in stdout.lines() { let parts: Vec<&str> = line.splitn(5, '|').collect(); if parts.len() < 5 { continue; } let hash = parts[0].to_string(); let full_hash = parts[1].to_string(); let subject = parts[2].to_string(); let relative_time = parts[3].to_string(); let author = parts[4].to_string(); // Check if commit has AI metadata (check git notes or commit trailers) let has_ai_metadata = check_ai_metadata(&full_hash); let is_top = top_hashes.contains(&full_hash); // Build display string let ai_indicator = if has_ai_metadata { "◆ " } else { " " }; let top_indicator = if is_top { "TOP " } else { " " }; let pretty = format!( "{}{}{} | {} | {} | {}", top_indicator, ai_indicator, hash, truncate_str(&subject, 50), relative_time, author ); let display = format!("{}\t{}", hash, pretty); commits.push(CommitEntry { hash, full_hash, subject, relative_time, author, has_ai_metadata, is_top, display, }); } Ok(commits) } fn list_commits_by_hashes(entries: &[TopEntry]) -> Result<Vec<CommitEntry>> { let mut commits = Vec::new(); for entry in entries { if let Some(commit) = load_commit_by_ref(&entry.hash)? { commits.push(commit); } } Ok(commits) } /// Check if a commit has AI session metadata attached. fn check_ai_metadata(commit_hash: &str) -> bool { // Check git notes for AI metadata let output = Command::new("git") .args(["notes", "show", commit_hash]) .output(); if let Ok(output) = output { if output.status.success() { let notes = String::from_utf8_lossy(&output.stdout); if notes.contains("ai-session") || notes.contains("claude") || notes.contains("codex") { return true; } } } // Check commit message for AI-related trailers let output = Command::new("git") .args(["log", "-1", "--format=%B", commit_hash]) .output(); if let Ok(output) = output { if output.status.success() { let body = String::from_utf8_lossy(&output.stdout).to_lowercase(); if body.contains("reviewed-by: codex") || body.contains("reviewed-by: claude") || body.contains("ai-session:") { return true; } } } false } enum CommitAction { Show, ToggleTop, } struct CommitSelection<'a> { entry: &'a CommitEntry, action: CommitAction, } /// Run fzf with preview for commits. fn run_commits_fzf(commits: &[CommitEntry]) -> Result<Option<CommitSelection<'_>>> { let mut child = Command::new("fzf") .arg("--prompt") .arg("commits> ") .arg("--ansi") .arg("--header") .arg("ctrl-t: toggle notable") .arg("--expect") .arg("ctrl-t") .arg("--delimiter") .arg("\t") .arg("--with-nth") .arg("2..") .arg("--preview") .arg("git -c log.showSignature=false show --stat --color=always {1}") .arg("--preview-window") .arg("down:50%:wrap") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for commit in commits { // Write with hash first for preview extraction writeln!(stdin, "{}", commit.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selection = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let mut lines = selection.lines(); let key = lines.next().unwrap_or(""); let selection = lines.next().unwrap_or("").trim(); if selection.is_empty() { return Ok(None); } let action = if key == "ctrl-t" { CommitAction::ToggleTop } else { CommitAction::Show }; let Some(entry) = commits.iter().find(|c| c.display == selection) else { return Ok(None); }; Ok(Some(CommitSelection { entry, action })) } /// Show detailed commit information including AI metadata. fn show_commit_details(commit: &CommitEntry) -> Result<()> { println!("\n────────────────────────────────────────"); println!("Commit: {} ({})", commit.hash, commit.relative_time); println!("Author: {}", commit.author); if commit.is_top { println!("Notable: yes"); } println!("────────────────────────────────────────\n"); // Show commit message let output = Command::new("git") .args(["log", "-1", "--format=%B", &commit.full_hash]) .output() .context("failed to get commit message")?; if output.status.success() { let message = String::from_utf8_lossy(&output.stdout); println!("Message:\n{}", message); } // Show AI metadata if present if commit.has_ai_metadata { println!("────────────────────────────────────────"); println!("AI Session Metadata:"); println!("────────────────────────────────────────\n"); // Try to get notes let notes_output = Command::new("git") .args(["notes", "show", &commit.full_hash]) .output(); if let Ok(notes) = notes_output { if notes.status.success() { let notes_content = String::from_utf8_lossy(¬es.stdout); println!("{}", notes_content); } } } // Show files changed println!("────────────────────────────────────────"); println!("Files Changed:"); println!("────────────────────────────────────────\n"); let files_output = Command::new("git") .args(["show", "--stat", "--format=", &commit.full_hash]) .output() .context("failed to get files changed")?; if files_output.status.success() { let files = String::from_utf8_lossy(&files_output.stdout); println!("{}", files); } Ok(()) } /// Truncate a string to a maximum length, adding "..." if truncated. fn truncate_str(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { // Find valid UTF-8 char boundary let target = max_len.saturating_sub(3); let mut end = target.min(s.len()); while end > 0 && !s.is_char_boundary(end) { end -= 1; } format!("{}...", &s[..end]) } } #[derive(Debug, Clone)] struct TopEntry { hash: String, label: Option<String>, } fn toggle_top_commit(commit: &CommitEntry) -> Result<()> { let mut entries = load_top_entries()?; if entries.iter().any(|entry| entry.hash == commit.full_hash) { entries.retain(|entry| entry.hash != commit.full_hash); write_top_entries(&entries)?; println!("Removed notable commit: {} {}", commit.hash, commit.subject); } else { let label = commit.subject.replace('\t', " "); entries.push(TopEntry { hash: commit.full_hash.clone(), label: Some(label), }); write_top_entries(&entries)?; println!("Marked notable: {} {}", commit.hash, commit.subject); } Ok(()) } fn load_commit_by_ref(commit_ref: &str) -> Result<Option<CommitEntry>> { let output = Command::new("git") .args(["log", "-1", "--pretty=format:%h|%H|%s|%ar|%an", commit_ref]) .output() .context("failed to run git log")?; if !output.status.success() { return Ok(None); } let stdout = String::from_utf8_lossy(&output.stdout); let line = stdout.lines().next().unwrap_or(""); let parts: Vec<&str> = line.splitn(5, '|').collect(); if parts.len() < 5 { return Ok(None); } let hash = parts[0].to_string(); let full_hash = parts[1].to_string(); let subject = parts[2].to_string(); let relative_time = parts[3].to_string(); let author = parts[4].to_string(); let has_ai_metadata = check_ai_metadata(&full_hash); let ai_indicator = if has_ai_metadata { "◆ " } else { " " }; let pretty = format!( "TOP {}{} | {} | {} | {}", ai_indicator, hash, truncate_str(&subject, 50), relative_time, author ); let display = format!("{}\t{}", hash, pretty); Ok(Some(CommitEntry { hash, full_hash, subject, relative_time, author, has_ai_metadata, is_top: true, display, })) } fn resolve_full_hash(commit_ref: &str) -> Result<String> { let output = Command::new("git") .args(["rev-parse", commit_ref]) .output() .context("failed to run git rev-parse")?; if !output.status.success() { bail!("commit not found: {}", commit_ref); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn load_top_entries() -> Result<Vec<TopEntry>> { let path = top_file_path()?; if !path.exists() { return Ok(Vec::new()); } let content = fs::read_to_string(&path).context("failed to read top commits")?; let mut entries = Vec::new(); for line in content.lines() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } let (hash, label) = match trimmed.split_once('\t') { Some((hash, label)) => (hash.to_string(), Some(label.to_string())), None => (trimmed.to_string(), None), }; entries.push(TopEntry { hash, label }); } Ok(entries) } fn write_top_entries(entries: &[TopEntry]) -> Result<()> { let path = top_file_path()?; if entries.is_empty() { if path.exists() { fs::remove_file(&path).context("failed to remove top commits file")?; } return Ok(()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent).context("failed to create top commits dir")?; } let mut out = String::new(); for entry in entries { if let Some(label) = &entry.label { out.push_str(&format!("{}\t{}\n", entry.hash, label)); } else { out.push_str(&format!("{}\n", entry.hash)); } } fs::write(&path, out).context("failed to write top commits")?; Ok(()) } fn top_hashes(entries: &[TopEntry]) -> HashSet<String> { entries.iter().map(|entry| entry.hash.clone()).collect() } fn top_file_path() -> Result<PathBuf> { let root = repo_root()?; Ok(root.join(TOP_COMMITS_PATH)) } fn repo_root() -> Result<PathBuf> { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output() .context("failed to run git rev-parse")?; if !output.status.success() { bail!("not inside a git repository"); } Ok(Path::new(String::from_utf8_lossy(&output.stdout).trim()).to_path_buf()) } ================================================ FILE: src/config.rs ================================================ use std::{ collections::HashMap, fs, path::{Path, PathBuf}, sync::OnceLock, time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; use serde::{Deserialize, Deserializer, Serialize}; use shellexpand::tilde; use crate::fixup; const CONFIG_CACHE_VERSION: u32 = 1; const CONFIG_CACHE_ENV_DISABLE: &str = "FLOW_DISABLE_CONFIG_CACHE"; /// Top-level configuration for flowd, currently focused on managed servers. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { #[serde(default)] pub version: Option<u32>, /// Optional human-friendly project name (applies to local project configs). #[serde( default, rename = "name", alias = "project_name", alias = "project-name" )] pub project_name: Option<String>, /// Optional env store space override for cloud. #[serde(default, rename = "env_space", alias = "env-space")] pub env_space: Option<String>, /// Env store scope: "project" (default) or "personal". #[serde( default, rename = "env_space_kind", alias = "env-space-kind", alias = "env-space-scope" )] pub env_space_kind: Option<String>, /// Flow-specific settings (primary_task, etc.) #[serde(default)] pub flow: FlowSettings, /// Project lifecycle orchestration for `f up` / `f down`. #[serde(default)] pub lifecycle: Option<LifecycleConfig>, /// Codex-first control plane settings. #[serde(default)] pub codex: Option<CodexConfig>, #[serde(default)] pub options: OptionsConfig, #[serde(default, alias = "server", alias = "server-local")] pub servers: Vec<ServerConfig>, #[serde(default, rename = "server-remote")] pub remote_servers: Vec<RemoteServerConfig>, #[serde(default)] pub tasks: Vec<TaskConfig>, /// Skills enforcement configuration (auto-sync/install). #[serde(default)] pub skills: Option<SkillsConfig>, /// Anonymous usage analytics settings. #[serde(default)] pub analytics: Option<AnalyticsConfig>, /// Hive agents defined for this project (array format: [[agent]]). #[serde(default, rename = "agent")] pub agents: Vec<crate::hive::AgentConfig>, /// Agent registry references (map format: [agents]). #[serde(default)] pub agents_registry: HashMap<String, String>, /// Everruns runtime defaults for `f ai everruns`. #[serde(default)] pub everruns: Option<EverrunsConfig>, #[serde(default, alias = "deps")] pub dependencies: HashMap<String, DependencySpec>, #[serde(default, alias = "alias", deserialize_with = "deserialize_aliases")] pub aliases: HashMap<String, String>, #[serde(default, rename = "commands")] pub command_files: Vec<CommandFileConfig>, #[serde(default)] pub storage: Option<StorageConfig>, #[serde(default)] pub flox: Option<FloxConfig>, #[serde(default, alias = "watcher", alias = "always-run")] pub watchers: Vec<WatcherConfig>, #[serde(default)] pub stream: Option<StreamConfig>, #[serde(default, rename = "server-hub")] pub server_hub: Option<ServerHubConfig>, /// Background daemons that flow can manage (start/stop/status). #[serde(default, alias = "daemon")] pub daemons: Vec<DaemonConfig>, /// Host deployment config for Linux servers. #[serde(default)] pub host: Option<crate::deploy::HostConfig>, /// Cloudflare Workers deployment config. #[serde(default)] pub cloudflare: Option<crate::deploy::CloudflareConfig>, /// Railway deployment config. #[serde(default)] pub railway: Option<crate::deploy::RailwayConfig>, /// Web deployment config. #[serde(default)] pub web: Option<crate::deploy::WebConfig>, /// Production deploy overrides (used by `f prod`). #[serde(default, alias = "production")] pub prod: Option<crate::deploy::ProdConfig>, /// Release configuration (hosts, npm, etc.). #[serde(default)] pub release: Option<ReleaseConfig>, /// Project invariants for AI-driven enforcement. #[serde(default)] pub invariants: Option<InvariantsConfig>, /// Commit workflow config (fixers, review instructions). #[serde(default)] pub commit: Option<CommitConfig>, /// Git workflow config (default remotes for push/sync). #[serde(default)] pub git: Option<GitConfig>, /// Jujutsu (jj) workflow config. #[serde(default)] pub jj: Option<JjConfig>, /// Setup defaults (global or project-level). #[serde(default)] pub setup: Option<SetupConfig>, /// Task lookup resolution policy for nested flow.toml discovery. #[serde( default, rename = "task_resolution", alias = "task-resolution", alias = "taskResolution" )] pub task_resolution: Option<TaskResolutionConfig>, /// SSH defaults (global or project-level). #[serde(default)] pub ssh: Option<SshConfig>, /// macOS launchd service management config. #[serde(default)] pub macos: Option<MacosConfig>, /// Proxy server configuration. #[serde(default)] pub proxy: Option<crate::proxy::ProxyConfig>, /// Proxy targets (array format: [[proxies]]). #[serde(default, alias = "proxy-target")] pub proxies: Vec<crate::proxy::ProxyTargetConfig>, /// Commit explanation config (AI-generated markdown summaries). #[serde(default, rename = "explain-commits", alias = "explain_commits")] pub explain_commits: Option<ExplainCommitsConfig>, } /// Commit explanation config — AI-generated markdown summaries per commit. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ExplainCommitsConfig { /// Whether auto-explain is enabled on sync (default: false). #[serde(default)] pub enabled: Option<bool>, /// Output directory relative to repo root (default: "docs/commits"). #[serde(default)] pub output_dir: Option<String>, /// AI model to use (default: "moonshotai/kimi-k2.5"). #[serde(default)] pub model: Option<String>, /// AI provider (default: "nvidia"). #[serde(default)] pub provider: Option<String>, /// Max commits to explain per sync (default: 10). #[serde(default)] pub batch_size: Option<usize>, } /// Everruns AI runtime defaults. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct EverrunsConfig { /// Everruns API base URL (for example: http://127.0.0.1:9300/api). #[serde(default, alias = "base-url", alias = "baseUrl")] pub base_url: Option<String>, /// Env var name that contains the API key (default: EVERRUNS_API_KEY). #[serde( default, rename = "api_key_env", alias = "api-key-env", alias = "apiKeyEnv" )] pub api_key_env: Option<String>, /// Default session id to reuse. #[serde( default, rename = "session_id", alias = "session-id", alias = "sessionId" )] pub session_id: Option<String>, /// Default agent id for new sessions. #[serde(default, rename = "agent_id", alias = "agent-id", alias = "agentId")] pub agent_id: Option<String>, /// Default harness id for new sessions. #[serde( default, rename = "harness_id", alias = "harness-id", alias = "harnessId" )] pub harness_id: Option<String>, /// Default model id override for new sessions. #[serde(default, rename = "model_id", alias = "model-id", alias = "modelId")] pub model_id: Option<String>, } /// macOS launchd service management config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct MacosConfig { /// Service patterns that are allowed (won't be flagged). /// Supports wildcards like "com.nikiv.*". #[serde(default)] pub allowed: Vec<String>, /// Service patterns that should be blocked/disabled. /// Supports wildcards like "com.google.*". #[serde(default)] pub blocked: Vec<String>, } /// SSH config (mode, key name, etc.). #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SshConfig { /// ssh mode: "auto", "force", or "https" #[serde(default)] pub mode: Option<String>, /// default key name to unlock (defaults to "default"). #[serde(default)] pub key_name: Option<String>, /// auto-unlock ssh keys when needed (default: true). #[serde(default)] pub auto_unlock: Option<bool>, } /// Configuration for commit workflow. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct CommitConfig { /// Pre-commit fixers to run before staging. /// Built-in: "mdx-comments", "trailing-whitespace", "end-of-file" /// Custom: "cmd:prettier --write" #[serde(default)] pub fixers: Vec<String>, /// Custom instructions passed to AI code review. #[serde(default)] pub review_instructions: Option<String>, /// File path to load review instructions from. #[serde(default)] pub review_instructions_file: Option<String>, /// Tool to use for commit review: "claude", "codex", "opencode", "kimi" #[serde(default)] pub tool: Option<String>, /// Model to use for commit review (tool-specific) #[serde(default)] pub model: Option<String>, /// Tool to use for commit message generation: "kimi" #[serde( default, rename = "message-tool", alias = "message_tool", alias = "messageTool" )] pub message_tool: Option<String>, /// Model to use for commit message generation (tool-specific) #[serde( default, rename = "message-model", alias = "message_model", alias = "messageModel" )] pub message_model: Option<String>, /// Continue commit if review fails after fallbacks (default: true) #[serde( default, rename = "review-fail-open", alias = "review_fail_open", alias = "reviewFailOpen" )] pub review_fail_open: Option<bool>, /// Continue commit if commit-message generation fails after fallbacks (default: true) #[serde( default, rename = "message-fail-open", alias = "message_fail_open", alias = "messageFailOpen" )] pub message_fail_open: Option<bool>, /// Optional ordered fallback chain for review tool/model. /// Examples: ["openrouter:openrouter/free", "claude", "codex-high"] #[serde( default, rename = "review-fallbacks", alias = "review_fallbacks", alias = "reviewFallbacks" )] pub review_fallbacks: Option<Vec<String>>, /// Optional ordered fallback chain for commit message generation. /// Examples: ["remote", "openai", "openrouter:openrouter/free", "heuristic"] #[serde( default, rename = "message-fallbacks", alias = "message_fallbacks", alias = "messageFallbacks" )] pub message_fallbacks: Option<Vec<String>>, /// Queue commits for review before push. #[serde(default)] pub queue: Option<bool>, /// Queue only when review finds issues (overrides queue if review passes). #[serde( default, rename = "queue_on_issues", alias = "queue-on-issues", alias = "queueOnIssues" )] pub queue_on_issues: Option<bool>, /// Use `f commit --quick` behavior by default (fast commit + async Codex deep review). #[serde( default, rename = "quick-default", alias = "quick_default", alias = "quickDefault" )] pub quick_default: Option<bool>, /// Quality gate configuration for commit-time feature doc/test enforcement. #[serde(default)] pub quality: Option<QualityConfig>, /// Test-runner enforcement and pre-commit test gate settings. #[serde(default)] pub testing: Option<TestingConfig>, /// Required workflow skills gate for commit-time enforcement. #[serde( default, rename = "skill_gate", alias = "skill-gate", alias = "skillGate" )] pub skill_gate: Option<SkillGateConfig>, /// Push gate for review todos: "warn" (default) | "block" | "off" #[serde( default, rename = "review-push-gate", alias = "review_push_gate", alias = "reviewPushGate" )] pub review_push_gate: Option<String>, } /// Quality gate configuration: enforce documentation and test requirements at commit time. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct QualityConfig { /// Gate mode: "warn" (default) | "block" | "off" #[serde(default)] pub mode: Option<String>, /// Require feature docs for touched features (default: true) #[serde(default)] pub require_docs: Option<bool>, /// Require test files for changed source code (default: true) #[serde(default)] pub require_tests: Option<bool>, /// Auto-generate/update feature docs at commit time (default: true) #[serde(default)] pub auto_generate_docs: Option<bool>, /// Doc detail level: "basic" | "detailed" (default: "basic") #[serde(default)] pub doc_level: Option<String>, /// Glob patterns exempt from quality checks #[serde(default)] pub exempt_paths: Option<Vec<String>>, /// Days before a feature doc is flagged stale (default: 30) #[serde(default)] pub stale_days: Option<u32>, } /// Testing gate configuration: enforce Bun test runner usage and quick local checks. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TestingConfig { /// Gate mode: "warn" (default) | "block" | "off" #[serde(default)] pub mode: Option<String>, /// Required runner (currently "bun" only). Default: "bun". #[serde(default)] pub runner: Option<String>, /// In Bun repo layout, require `bun bd test` instead of `bun test`. Default: true. #[serde(default)] pub bun_repo_strict: Option<bool>, /// Require at least one related test for staged source changes. Default: true. #[serde(default)] pub require_related_tests: Option<bool>, /// Directory for AI scratch tests (typically gitignored). Default: ".ai/test". #[serde(default)] pub ai_scratch_test_dir: Option<String>, /// Run AI scratch tests when no related tracked tests are detected. Default: true. #[serde(default)] pub run_ai_scratch_tests: Option<bool>, /// Allow AI scratch tests to satisfy related-test gate requirements. Default: false. #[serde(default)] pub allow_ai_scratch_to_satisfy_gate: Option<bool>, /// Soft budget in seconds for the local test gate; emits warning if exceeded. Default: 15. #[serde(default)] pub max_local_gate_seconds: Option<u64>, } /// Skill gate configuration: require specific workflow skills before commit. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SkillGateConfig { /// Gate mode: "warn" | "block" | "off" #[serde(default)] pub mode: Option<String>, /// Required skill names (must exist in .ai/skills). #[serde(default)] pub required: Vec<String>, /// Optional per-skill minimum version (from skill frontmatter "version"). #[serde(default, rename = "min_version", alias = "min-version")] pub min_version: Option<HashMap<String, u32>>, } /// Project invariants for AI-driven enforcement at commit time. /// /// Defines machine-parseable rules that flow checks against staged changes. /// Findings are injected into AI review prompts and can block or warn on commit. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct InvariantsConfig { /// Gate mode: "warn" (default) | "block" | "off" #[serde(default)] pub mode: Option<String>, /// Architecture style description (informational, injected into AI prompts). #[serde(default, rename = "architecture_style", alias = "architecture-style")] pub architecture_style: Option<String>, /// Non-negotiable patterns the project must follow (prose rules for AI context). #[serde(default, rename = "non_negotiable", alias = "non-negotiable")] pub non_negotiable: Vec<String>, /// Forbidden string patterns checked against staged diff content. /// If any pattern appears in the diff, a finding is emitted. #[serde(default)] pub forbidden: Vec<String>, /// Canonical terminology map: term -> definition. /// Injected into AI review prompts to prevent drift. #[serde(default)] pub terminology: HashMap<String, String>, /// Dependency policy sub-section. #[serde(default)] pub deps: Option<InvariantsDepsConfig>, /// File-level rules (max lines, etc.). #[serde(default)] pub files: Option<InvariantsFilesConfig>, } /// Dependency policy within invariants. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct InvariantsDepsConfig { /// Policy: "approval_required" (default) | "open" #[serde(default)] pub policy: Option<String>, /// Approved dependency names. New deps not on this list trigger a finding. #[serde(default)] pub approved: Vec<String>, } /// File-level invariant rules. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct InvariantsFilesConfig { /// Maximum lines per source file. Files exceeding this in the diff trigger a warning. #[serde(default, rename = "max_lines", alias = "max-lines")] pub max_lines: Option<u32>, } /// Jujutsu (jj) workflow config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct JjConfig { /// Default branch to rebase onto (e.g., "main"). #[serde( default, rename = "default_branch", alias = "default-branch", alias = "defaultBranch" )] pub default_branch: Option<String>, /// Default git remote (e.g., "origin"). #[serde(default)] pub remote: Option<String>, /// Auto-track bookmarks on create. #[serde( default, rename = "auto_track", alias = "auto-track", alias = "autoTrack" )] pub auto_track: Option<bool>, /// Home branch that review/codex branches stack on top of (for example: "nikiv"). #[serde( default, rename = "home_branch", alias = "home-branch", alias = "homeBranch" )] pub home_branch: Option<String>, /// Prefix for review bookmarks created by flow (e.g., "review"). #[serde( default, rename = "review_prefix", alias = "review-prefix", alias = "reviewPrefix" )] pub review_prefix: Option<String>, } /// Git workflow config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct GitConfig { /// Default writable remote used by flow commit/sync (e.g., "origin", "fork", "myflow-i"). #[serde(default)] pub remote: Option<String>, /// Enable private fork push (pushes to `{owner}/{repo}{suffix}` instead of origin). #[serde(default, rename = "fork-push", alias = "fork_push")] pub fork_push: Option<bool>, /// Suffix appended to repo name for fork push (default: "-i"). #[serde(default, rename = "fork-push-suffix", alias = "fork_push_suffix")] pub fork_push_suffix: Option<String>, /// GitHub owner for fork push (auto-detected from `gh api user` / `git config github.user`). #[serde(default, rename = "fork-push-owner", alias = "fork_push_owner")] pub fork_push_owner: Option<String>, } /// TypeScript config loaded from ~/.config/flow/config.ts #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TsConfig { #[serde(default)] pub flow: Option<TsFlowConfig>, } /// Flow section from TypeScript config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TsFlowConfig { #[serde(default)] pub commit: Option<TsCommitConfig>, #[serde(default)] pub review: Option<TsReviewConfig>, #[serde(default)] pub agents: Option<TsAgentsConfig>, #[serde(default)] pub env: Option<TsEnvConfig>, #[serde(default, rename = "taskFailureAgents")] pub task_failure_agents: Option<TsTaskFailureAgentsConfig>, /// Optional command to run on task failure. #[serde( default, rename = "taskFailureHook", alias = "task_failure_hook", alias = "task-failure-hook" )] pub task_failure_hook: Option<String>, /// Enable gitedit.dev hash in commit messages. Default false. #[serde(default)] pub gitedit: Option<bool>, /// Log level: "off", "error", "warn", "info", "debug", "trace". Default "warn". #[serde(default)] pub log_level: Option<String>, } /// Env settings from TypeScript config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TsEnvConfig { /// Preferred env backend: "cloud" or "local". #[serde(default)] pub backend: Option<String>, /// Env vars to inject into every task from the personal env store. #[serde( default, rename = "global_keys", alias = "globalKeys", alias = "global-keys" )] pub global_keys: Vec<String>, } /// Agents settings from TypeScript config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TsAgentsConfig { /// Tool to use: "claude", "gen", "opencode" #[serde(default)] pub tool: Option<String>, /// Default model for agents (e.g., "openrouter/moonshotai/kimi-k2:free") #[serde(default)] pub model: Option<String>, } /// Task-failure agent routing settings from TypeScript config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TsTaskFailureAgentsConfig { /// Enable auto-routing on task failure. #[serde(default)] pub enabled: Option<bool>, /// Tool to use (currently "hive"). #[serde(default)] pub tool: Option<String>, /// Max lines of task output to include in prompt. #[serde(default, rename = "maxLines")] pub max_lines: Option<usize>, /// Max chars of task output to include in prompt. #[serde(default, rename = "maxChars")] pub max_chars: Option<usize>, /// Max agents to run per failure. #[serde(default, rename = "maxAgents")] pub max_agents: Option<usize>, } /// Commit settings from TypeScript config. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TsCommitConfig { /// Tool to use: "claude", "codex", "opencode" #[serde(default)] pub tool: Option<String>, /// Model identifier (e.g., "opencode/minimax-m2.1-free") #[serde(default)] pub model: Option<String>, /// Tool to use for commit message generation: "kimi" #[serde( default, rename = "messageTool", alias = "message_tool", alias = "message-tool" )] pub message_tool: Option<String>, /// Model identifier for commit message generation #[serde( default, rename = "messageModel", alias = "message_model", alias = "message-model" )] pub message_model: Option<String>, /// Continue commit if review fails after fallbacks (default: true) #[serde( default, rename = "reviewFailOpen", alias = "review_fail_open", alias = "review-fail-open" )] pub review_fail_open: Option<bool>, /// Continue commit if commit-message generation fails after fallbacks (default: true) #[serde( default, rename = "messageFailOpen", alias = "message_fail_open", alias = "message-fail-open" )] pub message_fail_open: Option<bool>, /// Optional ordered fallback chain for review. #[serde( default, rename = "reviewFallbacks", alias = "review_fallbacks", alias = "review-fallbacks" )] pub review_fallbacks: Option<Vec<String>>, /// Optional ordered fallback chain for message generation. #[serde( default, rename = "messageFallbacks", alias = "message_fallbacks", alias = "message-fallbacks" )] pub message_fallbacks: Option<Vec<String>>, /// Custom review instructions #[serde(default)] pub review_instructions: Option<String>, /// Queue commits for review before push. #[serde(default)] pub queue: Option<bool>, /// Queue only when review finds issues (overrides queue if review passes). #[serde( default, rename = "queueOnIssues", alias = "queue_on_issues", alias = "queue-on-issues" )] pub queue_on_issues: Option<bool>, /// Use `f commit --quick` behavior by default. #[serde( default, rename = "quickDefault", alias = "quick_default", alias = "quick-default" )] pub quick_default: Option<bool>, /// Whether to run async (delegate to hub). Default true. #[serde(default, rename = "async")] pub async_enabled: Option<bool>, } /// Review settings from TypeScript config (overrides commit settings for review). #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TsReviewConfig { /// Tool to use for review: "claude", "codex", "opencode", "kimi" #[serde(default)] pub tool: Option<String>, /// Model identifier for review (e.g., "opencode/glm-4.7-free") #[serde(default)] pub model: Option<String>, } impl Default for Config { fn default() -> Self { Self { version: None, project_name: None, env_space: None, env_space_kind: None, flow: FlowSettings::default(), lifecycle: None, codex: None, options: OptionsConfig::default(), servers: Vec::new(), remote_servers: Vec::new(), tasks: Vec::new(), skills: None, analytics: None, agents: Vec::new(), agents_registry: HashMap::new(), everruns: None, dependencies: HashMap::new(), aliases: HashMap::new(), command_files: Vec::new(), storage: None, flox: None, watchers: Vec::new(), stream: None, server_hub: None, daemons: Vec::new(), host: None, cloudflare: None, railway: None, web: None, prod: None, release: None, invariants: None, commit: None, git: None, jj: None, setup: None, task_resolution: None, ssh: None, macos: None, proxy: None, proxies: Vec::new(), explain_commits: None, } } } /// Flow-specific settings for autonomous agent workflows. #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct FlowSettings { /// The primary task to run after code changes (e.g., "release", "deploy"). #[serde(default, alias = "primary-task")] pub primary_task: Option<String>, /// Task to run when invoking `f deploy release`. #[serde(default, rename = "release_task", alias = "release-task")] pub release_task: Option<String>, /// Task to run when invoking `f deploy` with no subcommand. #[serde(default, rename = "deploy_task", alias = "deploy-task")] pub deploy_task: Option<String>, } /// Project lifecycle configuration for `f up` and `f down`. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct LifecycleConfig { /// Task to run for `f up` (default fallback order: "up", then "dev"). #[serde(default, rename = "up_task", alias = "up-task", alias = "upTask")] pub up_task: Option<String>, /// Task to run for `f down` (default: "down"). #[serde(default, rename = "down_task", alias = "down-task", alias = "downTask")] pub down_task: Option<String>, /// Optional local-domain lifecycle behavior. #[serde(default)] pub domains: Option<LifecycleDomainsConfig>, } /// Optional local-domain automation used by lifecycle commands. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct LifecycleDomainsConfig { /// Hostname to map, for example: "myflow.localhost". #[serde(default, alias = "domain")] pub host: Option<String>, /// Upstream target in host:port format, for example: "127.0.0.1:3000". #[serde(default)] pub target: Option<String>, /// Extra host mappings to provision alongside the primary lifecycle domain. #[serde(default)] pub aliases: Vec<LifecycleDomainAliasConfig>, /// Domains engine: "docker" or "native" (default uses Flow global default). #[serde(default)] pub engine: Option<String>, /// Remove configured host mapping on `f down` (default: false). #[serde( default, rename = "remove_on_down", alias = "remove-on-down", alias = "removeOnDown" )] pub remove_on_down: Option<bool>, /// Stop shared domains proxy on `f down` (default: false). #[serde( default, rename = "stop_proxy_on_down", alias = "stop-proxy-on-down", alias = "stopProxyOnDown" )] pub stop_proxy_on_down: Option<bool>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct LifecycleDomainAliasConfig { /// Hostname to map, for example: "api.myflow.localhost". #[serde(default, alias = "domain")] pub host: Option<String>, /// Upstream target in host:port format, for example: "127.0.0.1:8780". #[serde(default)] pub target: Option<String>, } /// Skills enforcement configuration. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SkillsConfig { /// Auto-sync flow.toml tasks into .ai/skills. #[serde( default, rename = "sync_tasks", alias = "sync-tasks", alias = "syncTasks" )] pub sync_tasks: bool, /// Skills to install from the registry when missing. #[serde(default)] pub install: Vec<String>, /// Codex-specific skills behavior. #[serde(default)] pub codex: Option<SkillsCodexConfig>, /// Optional seq scraper integration for dependency skill generation. #[serde(default)] pub seq: Option<SkillsSeqConfig>, } /// Anonymous usage analytics settings. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct AnalyticsConfig { /// Force analytics enabled/disabled regardless of local prompt state. #[serde(default)] pub enabled: Option<bool>, /// Ingest endpoint for analytics events. #[serde(default)] pub endpoint: Option<String>, /// Client-side sampling rate (0.0..1.0, default 1.0). #[serde(default)] pub sample_rate: Option<f32>, } /// Codex-focused skills settings. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SkillsCodexConfig { /// Generate `agents/openai.yaml` metadata for task-synced skills. #[serde( default, rename = "generate_openai_yaml", alias = "generate-openai-yaml", alias = "generateOpenaiYaml" )] pub generate_openai_yaml: Option<bool>, /// After sync/install, force Codex app-server to reload skills for this cwd. #[serde( default, rename = "force_reload_after_sync", alias = "force-reload-after-sync", alias = "forceReloadAfterSync" )] pub force_reload_after_sync: Option<bool>, /// Default implicit invocation policy for task-synced skills metadata. #[serde( default, rename = "task_skill_allow_implicit_invocation", alias = "task-skill-allow-implicit-invocation", alias = "taskSkillAllowImplicitInvocation" )] pub task_skill_allow_implicit_invocation: Option<bool>, } /// Codex-first control plane settings. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct CodexConfig { /// Whether `f codex open` should auto-run reference resolvers when patterns match. #[serde( default, rename = "auto_resolve_references", alias = "auto-resolve-references", alias = "autoResolveReferences" )] pub auto_resolve_references: Option<bool>, /// Whether Flow may materialize per-launch runtime skills for Codex wrapper transports. #[serde( default, rename = "runtime_skills", alias = "runtime-skills", alias = "runtimeSkills" )] pub runtime_skills: Option<bool>, /// Default repo path used for home-session style Codex lookups. #[serde( default, rename = "home_session_path", alias = "home-session-path", alias = "homeSessionPath" )] pub home_session_path: Option<String>, /// Hard cap for injected prompt context before Codex sees the query. #[serde( default, rename = "prompt_context_budget_chars", alias = "prompt-context-budget-chars", alias = "promptContextBudgetChars" )] pub prompt_context_budget_chars: Option<usize>, /// Limit how many resolved references Flow may inject for one prompt. #[serde( default, rename = "max_resolved_references", alias = "max-resolved-references", alias = "maxResolvedReferences" )] pub max_resolved_references: Option<usize>, /// External reference resolvers that can unroll URLs or other tokens into compact context. #[serde( default, rename = "reference_resolver", alias = "reference-resolver", alias = "referenceResolver" )] pub reference_resolvers: Vec<CodexReferenceResolverConfig>, /// External skill repositories that Flow may scan/sync for Codex runtime injection. #[serde( default, rename = "skill_source", alias = "skill-source", alias = "skillSource" )] pub skill_sources: Vec<CodexSkillSourceConfig>, } impl CodexConfig { pub(crate) fn merge(&mut self, other: CodexConfig) { if other.auto_resolve_references.is_some() { self.auto_resolve_references = other.auto_resolve_references; } if other.runtime_skills.is_some() { self.runtime_skills = other.runtime_skills; } if other.home_session_path.is_some() { self.home_session_path = other.home_session_path; } if other.prompt_context_budget_chars.is_some() { self.prompt_context_budget_chars = other.prompt_context_budget_chars; } if other.max_resolved_references.is_some() { self.max_resolved_references = other.max_resolved_references; } for resolver in other.reference_resolvers { if let Some(existing) = self .reference_resolvers .iter_mut() .find(|value| value.name == resolver.name) { *existing = resolver; } else { self.reference_resolvers.push(resolver); } } for source in other.skill_sources { if let Some(existing) = self .skill_sources .iter_mut() .find(|value| value.name == source.name) { *existing = source; } else { self.skill_sources.push(source); } } } } /// External skill repository registration for Codex runtime helpers. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct CodexSkillSourceConfig { /// Human-friendly source name. pub name: String, /// Local path to the source repo or skill root. pub path: String, /// Whether this source is enabled for discovery/sync. #[serde(default)] pub enabled: Option<bool>, } /// External resolver registration for `f codex resolve` and `f codex open`. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct CodexReferenceResolverConfig { /// Human-friendly resolver name. pub name: String, /// Wildcard patterns that match candidate reference tokens. #[serde(default, rename = "match", alias = "matches")] pub matches: Vec<String>, /// Shell command template to run when a pattern matches. pub command: String, /// Optional label used when injecting the resolver output into the prompt. #[serde(default, rename = "inject_as", alias = "inject-as", alias = "injectAs")] pub inject_as: Option<String>, } /// Seq-backed skills fetch configuration. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SkillsSeqConfig { /// Fetch mode ("local-cli" today; "remote-api" reserved). #[serde(default)] pub mode: Option<String>, /// Path to seq repo (used to resolve tools/teach_deps.py). #[serde(default, rename = "seq_repo", alias = "seq-repo")] pub seq_repo: Option<String>, /// Full path to teach_deps.py (overrides seq_repo). #[serde(default, rename = "script_path", alias = "script-path")] pub script_path: Option<String>, /// Scraper daemon/API base URL. #[serde( default, rename = "scraper_base_url", alias = "scraper-base-url", alias = "scraperBaseUrl" )] pub scraper_base_url: Option<String>, /// Scraper bearer token. #[serde( default, rename = "scraper_api_key", alias = "scraper-api-key", alias = "scraperApiKey" )] pub scraper_api_key: Option<String>, /// Output directory for generated skills. #[serde(default, rename = "out_dir", alias = "out-dir")] pub out_dir: Option<String>, /// Cache TTL in hours. #[serde( default, rename = "cache_ttl_hours", alias = "cache-ttl-hours", alias = "cacheTtlHours" )] pub cache_ttl_hours: Option<f64>, /// Direct fetch fallback when scraper queue is unavailable. #[serde( default, rename = "allow_direct_fallback", alias = "allow-direct-fallback", alias = "allowDirectFallback" )] pub allow_direct_fallback: Option<bool>, /// Optional seq.mem JSONEachRow destination path. #[serde( default, rename = "mem_events_path", alias = "mem-events-path", alias = "memEventsPath" )] pub mem_events_path: Option<String>, /// Default per-ecosystem dependency count for auto mode. #[serde(default)] pub top: Option<usize>, /// Default ecosystems for auto mode. #[serde(default)] pub ecosystems: Option<String>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ReleaseConfig { /// Default release provider (e.g., "registry", "task"). #[serde(default)] pub default: Option<String>, /// Versioning scheme (e.g., "calver"). #[serde(default)] pub versioning: Option<String>, /// Optional suffix for calver (appended as pre-release, e.g., "1" -> 2026.1.12-1). #[serde(default)] pub calver_suffix: Option<String>, /// Release host domain. #[serde(default)] pub domain: Option<String>, /// Base URL for release artifacts. #[serde(default)] pub base_url: Option<String>, /// Release host root path. #[serde(default)] pub root: Option<String>, /// Caddyfile path. #[serde(default)] pub caddyfile: Option<String>, /// Readme file path to update. #[serde(default)] pub readme: Option<String>, /// Flow registry release config. #[serde(default)] pub registry: Option<RegistryReleaseConfig>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct RegistryReleaseConfig { /// Base URL for the registry (e.g., "https://myflow.sh"). #[serde(default)] pub url: Option<String>, /// Registry package name (defaults to project name). #[serde(default)] pub package: Option<String>, /// Optional binary names to upload. #[serde(default)] pub bins: Option<Vec<String>>, /// Default binary name to install. #[serde(default)] pub default_bin: Option<String>, /// Env var that holds the registry token. #[serde(default)] pub token_env: Option<String>, /// Whether to update the latest pointer by default. #[serde(default)] pub latest: Option<bool>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SetupConfig { /// Server setup defaults (used by f setup release). #[serde(default)] pub server: Option<SetupServerConfig>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct SetupServerConfig { /// Optional template flow.toml path to pull [host] defaults from. pub template: Option<String>, /// Optional inline [host] defaults. #[serde(default)] pub host: Option<crate::deploy::HostConfig>, } /// Task lookup policy for nested flow.toml discovery. #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TaskResolutionConfig { /// Preferred scope order for ambiguous task names (e.g. ["mobile", "root"]). #[serde( default, rename = "preferred_scopes", alias = "preferred-scopes", alias = "preferredScopes" )] pub preferred_scopes: Vec<String>, /// Exact task-name routes (task -> scope), used before preferred scope order. #[serde(default)] pub routes: HashMap<String, String>, /// Print a note when implicit scope routing chooses a target. #[serde( default, rename = "warn_on_implicit_scope", alias = "warn-on-implicit-scope", alias = "warnOnImplicitScope" )] pub warn_on_implicit_scope: Option<bool>, } /// Global feature toggles. #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct OptionsConfig { #[serde(default, rename = "trace_terminal_io")] pub trace_terminal_io: bool, #[serde( default, rename = "commit_with_check_async", alias = "commit-with-check-async" )] pub commit_with_check_async: Option<bool>, #[serde( default, rename = "commit_with_check_use_repo_root", alias = "commit-with-check-use-repo-root" )] pub commit_with_check_use_repo_root: Option<bool>, #[serde( default, rename = "commit_with_check_timeout_secs", alias = "commit-with-check-timeout-secs" )] pub commit_with_check_timeout_secs: Option<u64>, /// Number of retries when review times out (default 1). #[serde( default, rename = "commit_with_check_review_retries", alias = "commit-with-check-review-retries" )] pub commit_with_check_review_retries: Option<u32>, /// Remote Claude review URL for commitWithCheck. #[serde( default, rename = "commit_with_check_review_url", alias = "commit-with-check-review-url" )] pub commit_with_check_review_url: Option<String>, /// Optional auth token for remote review. #[serde( default, rename = "commit_with_check_review_token", alias = "commit-with-check-review-token" )] pub commit_with_check_review_token: Option<String>, /// Enable mirroring commits to gitedit.dev for commitWithCheck. #[serde( default, rename = "commit_with_check_gitedit_mirror", alias = "commit-with-check-gitedit-mirror" )] pub commit_with_check_gitedit_mirror: Option<bool>, /// Enable mirroring commits to gitedit.dev (opt-in per project). #[serde(default, rename = "gitedit_mirror", alias = "gitedit-mirror")] pub gitedit_mirror: Option<bool>, /// Custom gitedit API URL (defaults to https://gitedit.dev). #[serde(default, rename = "gitedit_url", alias = "gitedit-url")] pub gitedit_url: Option<String>, /// Override repo full name for gitedit sync (e.g., "giteditdev/gitedit"). #[serde( default, rename = "gitedit_repo_full_name", alias = "gitedit-repo-full-name" )] pub gitedit_repo_full_name: Option<String>, /// Optional token for gitedit sync/publish. #[serde(default, rename = "gitedit_token", alias = "gitedit-token")] pub gitedit_token: Option<String>, /// Enable mirroring commits to myflow.sh (opt-in per project). #[serde(default, rename = "myflow_mirror", alias = "myflow-mirror")] pub myflow_mirror: Option<bool>, /// Custom myflow API URL (defaults to https://myflow.sh). #[serde(default, rename = "myflow_url", alias = "myflow-url")] pub myflow_url: Option<String>, /// Optional token for myflow sync. #[serde(default, rename = "myflow_token", alias = "myflow-token")] pub myflow_token: Option<String>, /// Override Codex binary path/name (defaults to "codex"). /// Useful for wrapper transports that still support `app-server` JSON-RPC. #[serde(default, rename = "codex_bin", alias = "codex-bin")] pub codex_bin: Option<String>, } impl OptionsConfig { fn merge(&mut self, other: OptionsConfig) { if other.trace_terminal_io { self.trace_terminal_io = true; } if other.commit_with_check_async.is_some() { self.commit_with_check_async = other.commit_with_check_async; } if other.commit_with_check_use_repo_root.is_some() { self.commit_with_check_use_repo_root = other.commit_with_check_use_repo_root; } if other.commit_with_check_timeout_secs.is_some() { self.commit_with_check_timeout_secs = other.commit_with_check_timeout_secs; } if other.commit_with_check_review_retries.is_some() { self.commit_with_check_review_retries = other.commit_with_check_review_retries; } if other.commit_with_check_review_url.is_some() { self.commit_with_check_review_url = other.commit_with_check_review_url; } if other.commit_with_check_review_token.is_some() { self.commit_with_check_review_token = other.commit_with_check_review_token; } if other.commit_with_check_gitedit_mirror.is_some() { self.commit_with_check_gitedit_mirror = other.commit_with_check_gitedit_mirror; } if other.gitedit_mirror.is_some() { self.gitedit_mirror = other.gitedit_mirror; } if other.gitedit_url.is_some() { self.gitedit_url = other.gitedit_url; } if other.gitedit_repo_full_name.is_some() { self.gitedit_repo_full_name = other.gitedit_repo_full_name; } if other.gitedit_token.is_some() { self.gitedit_token = other.gitedit_token; } if other.myflow_mirror.is_some() { self.myflow_mirror = other.myflow_mirror; } if other.myflow_url.is_some() { self.myflow_url = other.myflow_url; } if other.myflow_token.is_some() { self.myflow_token = other.myflow_token; } if other.codex_bin.is_some() { self.codex_bin = other.codex_bin; } } } impl TaskResolutionConfig { fn merge(&mut self, other: TaskResolutionConfig) { if self.preferred_scopes.is_empty() { self.preferred_scopes = other.preferred_scopes; } else { for scope in other.preferred_scopes { if !self.preferred_scopes.iter().any(|s| s == &scope) { self.preferred_scopes.push(scope); } } } if self.warn_on_implicit_scope.is_none() { self.warn_on_implicit_scope = other.warn_on_implicit_scope; } for (task, scope) in other.routes { self.routes.entry(task).or_insert(scope); } } } /// Configuration for a single managed HTTP server process. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct ServerConfig { /// Human-friendly name used in the TUI and HTTP API. pub name: String, /// Program to execute, e.g. "node", "cargo". pub command: String, /// Arguments passed to the command. pub args: Vec<String>, /// Optional port the server listens on (for display only). pub port: Option<u16>, /// Optional working directory for the process. pub working_dir: Option<PathBuf>, /// Additional environment variables. pub env: HashMap<String, String>, /// Whether this server should be started automatically with the daemon. pub autostart: bool, } impl ServerConfig { pub fn to_daemon_config(&self) -> DaemonConfig { DaemonConfig { name: self.name.clone(), binary: self.command.clone(), command: None, args: self.args.clone(), working_dir: self .working_dir .as_ref() .map(|p| p.to_string_lossy().to_string()), port: self.port, env: self.env.clone(), autostart: self.autostart, restart: Some(DaemonRestartPolicy::OnFailure), description: Some(format!("Dev server: {}", self.name)), health_url: None, health_socket: None, host: None, boot: false, autostop: false, retry: Some(3), ready_delay: None, ready_output: None, } } } impl<'de> Deserialize<'de> for ServerConfig { fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] struct RawServerConfig { #[serde(default)] name: Option<String>, command: String, #[serde(default)] args: Vec<String>, #[serde(default)] port: Option<u16>, #[serde(default, alias = "path")] working_dir: Option<String>, #[serde(default)] env: HashMap<String, String>, #[serde(default = "default_autostart")] autostart: bool, } let raw = RawServerConfig::deserialize(deserializer)?; let mut command = raw.command; let mut args = raw.args; if args.is_empty() { if let Ok(parts) = shell_words::split(&command) { if let Some((head, tail)) = parts.split_first() { command = head.clone(); args = tail.to_vec(); } } } let name = raw .name .or_else(|| { raw.working_dir.as_ref().and_then(|dir| { Path::new(dir) .file_name() .map(|n| n.to_string_lossy().to_string()) .filter(|s| !s.is_empty()) }) }) .unwrap_or_else(|| { if command.is_empty() { "server".to_string() } else { command.clone() } }); let command = expand_path(&command).to_string_lossy().into_owned(); Ok(ServerConfig { name, command, args, port: raw.port, working_dir: raw.working_dir.map(|dir| expand_path(&dir)), env: raw.env, autostart: raw.autostart, }) } } fn default_autostart() -> bool { true } /// Local project automation task description. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TaskConfig { /// Unique identifier for the task (used when selecting it interactively). pub name: String, /// Shell command that should be executed for this task. pub command: String, /// Whether this task should be handed off to the hub daemon instead of running locally. #[serde(default, rename = "delegate-to-hub", alias = "delegate_to_hub")] pub delegate_to_hub: bool, /// Whether this task should run automatically when entering the project root. #[serde(default)] pub activate_on_cd_to_root: bool, /// Optional task-specific dependencies that must be made available before the command runs. #[serde(default)] pub dependencies: Vec<String>, /// Optional human-friendly description. #[serde(default, alias = "desc")] pub description: Option<String>, /// Optional short aliases that `f run` should recognize (e.g. "dcr" for "deploy-cli-release"). #[serde( default, alias = "shortcut", alias = "short", deserialize_with = "deserialize_shortcuts" )] pub shortcuts: Vec<String>, /// Whether this task requires interactive input (stdin passthrough, TTY). #[serde(default)] pub interactive: bool, /// Require confirmation when matched via LM Studio (for destructive tasks). #[serde(default, alias = "confirm-on-match")] pub confirm_on_match: bool, /// Command to run when the task is cancelled (Ctrl+C). #[serde(default, alias = "on-cancel")] pub on_cancel: Option<String>, /// Optional file path to save combined task output (relative to project root unless absolute). #[serde(default, alias = "output-file")] pub output_file: Option<String>, } /// Definition of a dependency that can be referenced by automation tasks. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum DependencySpec { /// Single command/binary that should be available on PATH. Single(String), /// Multiple commands that should be checked together. Multiple(Vec<String>), /// Flox package descriptor that should be added to the local env manifest. Flox(FloxInstallSpec), } fn deserialize_shortcuts<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum ShortcutField { Single(String), Multiple(Vec<String>), } let value = Option::<ShortcutField>::deserialize(deserializer)?; let shortcuts = match value { Some(ShortcutField::Single(alias)) => vec![alias], Some(ShortcutField::Multiple(aliases)) => aliases, None => Vec::new(), }; Ok(shortcuts) } /// Storage configuration describing remote environments providers. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct StorageConfig { /// Provider identifier understood by the hosted hub. pub provider: String, /// Environment variable that stores the API key/token. #[serde(default = "default_storage_env_var")] pub env_var: String, /// Base URL for the storage hub (defaults to hosted flow hub). #[serde(default = "default_hub_url")] pub hub_url: String, /// Environments that can be synced locally. #[serde(default)] pub envs: Vec<StorageEnvConfig>, } fn default_hub_url() -> String { "https://myflow.sh".to_string() } fn default_storage_env_var() -> String { "FLOW_SECRETS_TOKEN".to_string() } /// Definition of an environment with named variables. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct StorageEnvConfig { pub name: String, #[serde(default)] pub description: Option<String>, #[serde(default)] pub variables: Vec<StorageVariable>, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct StorageVariable { pub key: String, #[serde(default)] pub default: Option<String>, } /// Flox manifest-style configuration (install set, etc.). #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct FloxConfig { #[serde(default, rename = "install", alias = "deps")] pub install: HashMap<String, FloxInstallSpec>, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FloxInstallSpec { #[serde(rename = "pkg-path")] pub pkg_path: String, #[serde(default, rename = "pkg-group")] pub pkg_group: Option<String>, #[serde(default)] pub version: Option<String>, #[serde(default)] pub systems: Option<Vec<String>>, #[serde(default)] pub priority: Option<i64>, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CommandFileConfig { pub path: String, #[serde(default)] pub description: Option<String>, } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RemoteServerConfig { #[serde(flatten)] pub server: ServerConfig, /// Optional hub name that coordinates this remote process. #[serde(default)] pub hub: Option<String>, /// Paths to sync to the remote hub before launching. #[serde(default)] pub sync_paths: Vec<PathBuf>, } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerHubConfig { pub name: String, pub host: String, #[serde(default = "default_server_hub_port")] pub port: u16, #[serde(default)] pub tailscale: Option<String>, #[serde(default)] pub description: Option<String>, } fn default_server_hub_port() -> u16 { 9050 } /// File watcher configuration for local automation. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct WatcherConfig { #[serde(default)] pub driver: WatcherDriver, pub name: String, pub path: String, #[serde(default, rename = "match")] pub filter: Option<String>, #[serde(default)] pub command: Option<String>, #[serde(default = "default_debounce_ms")] pub debounce_ms: u64, #[serde(default)] pub run_on_start: bool, #[serde(default)] pub env: HashMap<String, String>, #[serde(default)] pub poltergeist: Option<PoltergeistConfig>, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum WatcherDriver { Shell, Poltergeist, } impl Default for WatcherDriver { fn default() -> Self { WatcherDriver::Shell } } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PoltergeistConfig { #[serde(default = "default_poltergeist_binary")] pub binary: String, #[serde(default)] pub mode: PoltergeistMode, #[serde(default)] pub args: Vec<String>, } impl Default for PoltergeistConfig { fn default() -> Self { Self { binary: default_poltergeist_binary(), mode: PoltergeistMode::Haunt, args: Vec::new(), } } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PoltergeistMode { Haunt, Panel, Status, } impl Default for PoltergeistMode { fn default() -> Self { PoltergeistMode::Haunt } } fn default_debounce_ms() -> u64 { 200 } fn default_poltergeist_binary() -> String { "poltergeist".to_string() } impl PoltergeistMode { pub fn as_subcommand(&self) -> &'static str { match self { PoltergeistMode::Haunt => "haunt", PoltergeistMode::Panel => "panel", PoltergeistMode::Status => "status", } } } /// Streaming configuration handled by the hub (stub for future OBS integration). #[derive(Debug, Clone, Deserialize, Serialize)] pub struct StreamConfig { pub provider: String, #[serde(default)] pub hotkey: Option<String>, #[serde(default)] pub toggle_url: Option<String>, } /// Restart behavior for managed daemons. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum DaemonRestartPolicy { Never, OnFailure, Always, } /// Configuration for a background daemon that flow can manage. /// /// Example in flow.toml: /// ```toml /// [[daemon]] /// name = "lin" /// binary = "lin" /// command = "daemon" /// args = ["--host", "127.0.0.1", "--port", "9050"] /// health_url = "http://127.0.0.1:9050/health" /// /// [[daemon]] /// name = "base" /// binary = "base" /// command = "jazz" /// args = ["--port", "7201"] /// health_url = "http://127.0.0.1:7201/health" /// working_dir = "~/code/myflow" /// ``` #[derive(Debug, Clone, Deserialize, Serialize)] pub struct DaemonConfig { /// Unique name for this daemon (used in `f daemon start <name>`). pub name: String, /// Binary to execute (can be a name on PATH or absolute path). pub binary: String, /// Subcommand to run the daemon (e.g., "daemon", "jazz", "serve"). #[serde(default)] pub command: Option<String>, /// Additional arguments passed after the command. #[serde(default)] pub args: Vec<String>, /// Health check URL to determine if daemon is running. #[serde(default, alias = "health")] pub health_url: Option<String>, /// Unix socket path to probe to determine if a daemon is running. #[serde(default, alias = "health-socket", alias = "health_socket")] pub health_socket: Option<String>, /// Port the daemon listens on (extracted from health_url if not specified). #[serde(default)] pub port: Option<u16>, /// Host the daemon binds to. #[serde(default)] pub host: Option<String>, /// Working directory for the daemon process. #[serde(default, alias = "path")] pub working_dir: Option<String>, /// Environment variables to set for the daemon. #[serde(default)] pub env: HashMap<String, String>, /// Whether to start this daemon automatically when flow starts. #[serde(default)] pub autostart: bool, /// Whether to stop this daemon when leaving the project. #[serde(default)] pub autostop: bool, /// Whether to start this daemon during boot/startup sessions. #[serde(default)] pub boot: bool, /// Restart policy (never, on-failure, always). #[serde(default)] pub restart: Option<DaemonRestartPolicy>, /// Maximum restart attempts (optional). #[serde(default)] pub retry: Option<u32>, /// Milliseconds to wait before considering the daemon ready. #[serde(default)] pub ready_delay: Option<u64>, /// Output pattern (string or regex) to match for readiness. #[serde(default)] pub ready_output: Option<String>, /// Description of what this daemon does. #[serde(default)] pub description: Option<String>, } impl DaemonConfig { /// Get the effective health URL, building from host/port if not specified. pub fn effective_health_url(&self) -> Option<String> { if let Some(url) = &self.health_url { return Some(url.clone()); } let host = self.host.as_deref().unwrap_or("127.0.0.1"); self.port.map(|p| format!("http://{}:{}/health", host, p)) } /// Get the effective unix socket health target, if configured. pub fn effective_health_socket(&self) -> Option<PathBuf> { self.health_socket.as_ref().map(|path| expand_path(path)) } /// Get the effective host. pub fn effective_host(&self) -> &str { self.host.as_deref().unwrap_or("127.0.0.1") } /// Human-readable health target for status output. pub fn health_target_label(&self) -> Option<String> { if let Some(url) = self.effective_health_url() { return Some(url.replace("/health", "")); } self.effective_health_socket() .map(|path| format!("unix:{}", path.display())) } } impl DependencySpec { /// Add one or more command names to the provided buffer. pub fn extend_commands(&self, buffer: &mut Vec<String>) { match self { DependencySpec::Single(cmd) => buffer.push(cmd.clone()), DependencySpec::Multiple(cmds) => buffer.extend(cmds.iter().cloned()), DependencySpec::Flox(_) => {} } } } fn deserialize_aliases<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error> where D: Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum AliasInput { Map(HashMap<String, String>), List(Vec<HashMap<String, String>>), } let maybe = Option::<AliasInput>::deserialize(deserializer)?; let mut aliases = HashMap::new(); if let Some(input) = maybe { match input { AliasInput::Map(map) => aliases = map, AliasInput::List(list) => { for table in list { for (name, command) in table { aliases.insert(name, command); } } } } } Ok(aliases) } /// Default config path: ~/.config/flow/flow.toml (falls back to legacy config.toml) pub fn default_config_path() -> PathBuf { let base = global_config_dir(); let primary = base.join("flow.toml"); if primary.exists() { return primary; } let legacy = base.join("config.toml"); if legacy.exists() { tracing::warn!("using legacy config path ~/.config/flow/config.toml; rename to flow.toml"); return legacy; } primary } /// Global config directory: ~/.config/flow pub fn global_config_dir() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".config/flow") } fn legacy_global_state_dir_for(config_dir: &Path) -> PathBuf { config_dir.with_file_name("flow-state") } fn select_global_state_dir(config_dir: &Path) -> PathBuf { let legacy_dir = legacy_global_state_dir_for(config_dir); if is_dir_path(&legacy_dir) { return legacy_dir; } if is_dir_path(config_dir) || !config_dir.exists() { return config_dir.to_path_buf(); } legacy_dir } /// Ensure the global config directory exists (moves aside files that block it). pub fn ensure_global_config_dir() -> Result<PathBuf> { let dir = global_config_dir(); if let Some(parent) = dir.parent() { ensure_dir(parent)?; } ensure_dir(&dir)?; Ok(dir) } /// Global state directory for runtime data. pub fn global_state_dir() -> PathBuf { let config_dir = global_config_dir(); select_global_state_dir(&config_dir) } /// Global state directory candidates (primary first) for migration-safe readers. pub fn global_state_dir_candidates() -> Vec<PathBuf> { let config_dir = global_config_dir(); let legacy_dir = legacy_global_state_dir_for(&config_dir); let primary = select_global_state_dir(&config_dir); let mut candidates = vec![primary.clone()]; for candidate in [config_dir, legacy_dir] { if candidate != primary && is_dir_path(&candidate) { candidates.push(candidate); } } candidates } /// Ensure the global state directory exists. pub fn ensure_global_state_dir() -> Result<PathBuf> { let dir = global_state_dir(); if let Some(parent) = dir.parent() { ensure_dir(parent)?; } ensure_dir(&dir)?; Ok(dir) } fn ensure_dir(path: &Path) -> Result<()> { if let Ok(meta) = fs::symlink_metadata(path) { let is_dir = meta.is_dir(); let is_symlink = meta.file_type().is_symlink(); if is_dir { return Ok(()); } if is_symlink { if let Ok(target_meta) = fs::metadata(path) { if target_meta.is_dir() { return Ok(()); } } } let backup = backup_path(path); fs::rename(path, &backup).with_context(|| { format!( "failed to move existing {} to {}", path.display(), backup.display() ) })?; tracing::warn!( "moved blocking path {} to {}", path.display(), backup.display() ); } fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))?; Ok(()) } fn is_dir_path(path: &Path) -> bool { if let Ok(meta) = fs::symlink_metadata(path) { if meta.is_dir() { return true; } if meta.file_type().is_symlink() { if let Ok(target_meta) = fs::metadata(path) { return target_meta.is_dir(); } } } false } fn backup_path(path: &Path) -> PathBuf { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("flow"); let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); path.with_file_name(format!("{}-archive-{}", name, ts)) } /// Load global secrets from ~/.config/flow/secrets.toml pub fn load_global_secrets() { let secrets_path = global_config_dir().join("secrets.toml"); if secrets_path.exists() { if let Ok(secrets) = load_secrets(&secrets_path) { let mut dummy = Config::default(); merge_secrets(&mut dummy, secrets); tracing::debug!(path = %secrets_path.display(), "loaded global secrets"); } } } /// Path to TypeScript config: ~/.config/flow/config.ts pub fn ts_config_path() -> PathBuf { global_config_dir().join("config.ts") } /// Load TypeScript config from ~/.config/flow/config.ts using bun. /// Returns None if config.ts doesn't exist or fails to load. pub fn load_ts_config() -> Option<TsConfig> { let config_path = ts_config_path(); if !config_path.exists() { return None; } // Use bun to evaluate the TypeScript and output JSON let loader_script = format!( r#"const config = await import("{}"); console.log(JSON.stringify(config.default || config));"#, config_path.display() ); let mut child = std::process::Command::new("bun") .args(["run", "-"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .ok()?; if let Some(mut stdin) = child.stdin.take() { use std::io::Write; let _ = stdin.write_all(loader_script.as_bytes()); } let output = child.wait_with_output().ok()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!("failed to load config.ts: {}", stderr.trim()); return None; } let json = String::from_utf8_lossy(&output.stdout); match serde_json::from_str::<TsConfig>(json.trim()) { Ok(config) => { tracing::debug!(path = %config_path.display(), "loaded TypeScript config"); Some(config) } Err(err) => { tracing::warn!("failed to parse config.ts output: {}", err); None } } } /// Preferred env backend from ~/.config/flow/config.ts ("cloud" or "local"). pub fn preferred_env_backend() -> Option<String> { let config = load_ts_config()?; let backend = config.flow?.env?.backend?; let trimmed = backend.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_ascii_lowercase()) } /// Env vars to inject into every task from the personal env store. /// Defaults to AI server connection vars unless overridden in config.ts. pub fn global_env_keys() -> Vec<String> { static GLOBAL_KEYS: OnceLock<Vec<String>> = OnceLock::new(); GLOBAL_KEYS .get_or_init(|| { let mut keys = vec![ "AI_SERVER_URL".to_string(), "AI_SERVER_TOKEN".to_string(), "AI_SERVER_MODEL".to_string(), "ZAI_API_KEY".to_string(), ]; if let Some(config) = load_ts_config() { if let Some(env) = config.flow.and_then(|flow| flow.env) { if !env.global_keys.is_empty() { keys = env.global_keys; } } } keys }) .clone() } pub fn expand_path(raw: &str) -> PathBuf { let tilde_expanded = tilde(raw).into_owned(); let env_expanded = match shellexpand::env(&tilde_expanded) { Ok(val) => val.into_owned(), Err(_) => tilde_expanded, }; PathBuf::from(env_expanded) } #[derive(Debug, Clone, Serialize, Deserialize)] struct ConfigCacheEntry { version: u32, config: Config, watched: Vec<ConfigPathStamp>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ConfigPathStamp { path: PathBuf, is_dir: bool, len: u64, modified_sec: u64, modified_nsec: u32, } #[derive(Debug)] struct ConfigLoadArtifacts { config: Config, watched_paths: Vec<PathBuf>, } pub fn load<P: AsRef<Path>>(path: P) -> Result<Config> { let path = path.as_ref(); if config_cache_disabled() { let mut cfg = load_uncached(path)?.config; load_sibling_secrets(&mut cfg, path); return Ok(cfg); } let canonical = path .canonicalize() .with_context(|| format!("failed to resolve path {}", path.display()))?; let cache_path = config_cache_path(&canonical); if let Some(entry) = read_config_cache(&cache_path) && config_stamps_match(&entry.watched) { let mut cfg = entry.config; load_sibling_secrets(&mut cfg, &canonical); return Ok(cfg); } let artifacts = load_uncached(&canonical)?; let mut cfg = artifacts.config.clone(); load_sibling_secrets(&mut cfg, &canonical); let cache = ConfigCacheEntry { version: CONFIG_CACHE_VERSION, config: artifacts.config, watched: config_stamps_for_paths(&artifacts.watched_paths), }; if let Err(err) = write_config_cache(&cache_path, &cache) { tracing::debug!(path = %cache_path.display(), error = %err, "failed to write config cache"); } Ok(cfg) } /// Secrets that can be loaded from a separate file to avoid exposing on stream. #[derive(Debug, Clone, Default, Deserialize)] struct Secrets { #[serde(default)] env: HashMap<String, String>, #[serde(default)] cloudflare: Option<CloudflareSecrets>, #[serde(default)] openai: Option<ApiKeySecret>, #[serde(default)] anthropic: Option<ApiKeySecret>, #[serde(default)] cerebras: Option<ApiKeySecret>, } #[derive(Debug, Clone, Default, Deserialize)] struct CloudflareSecrets { account_id: Option<String>, stream_token: Option<String>, stream_key: Option<String>, } #[derive(Debug, Clone, Default, Deserialize)] struct ApiKeySecret { #[serde(alias = "api_key", alias = "key")] api_key: Option<String>, } fn load_secrets(path: &Path) -> Result<Secrets> { let contents = fs::read_to_string(path) .with_context(|| format!("failed to read secrets at {}", path.display()))?; let secrets: Secrets = toml::from_str(&contents) .with_context(|| format!("failed to parse secrets at {}", path.display()))?; Ok(secrets) } fn merge_secrets(cfg: &mut Config, secrets: Secrets) { // Inject secrets as environment variables for child processes // SAFETY: We're setting env vars at startup before any threads are spawned unsafe { for (key, value) in secrets.env { std::env::set_var(&key, &value); } if let Some(cf) = secrets.cloudflare { if let Some(v) = cf.account_id { std::env::set_var("CLOUDFLARE_ACCOUNT_ID", &v); } if let Some(v) = cf.stream_token { std::env::set_var("CLOUDFLARE_STREAM_TOKEN", &v); } if let Some(v) = cf.stream_key { std::env::set_var("CLOUDFLARE_STREAM_KEY", &v); } } if let Some(openai) = secrets.openai { if let Some(v) = openai.api_key { std::env::set_var("OPENAI_API_KEY", &v); } } if let Some(anthropic) = secrets.anthropic { if let Some(v) = anthropic.api_key { std::env::set_var("ANTHROPIC_API_KEY", &v); } } if let Some(cerebras) = secrets.cerebras { if let Some(v) = cerebras.api_key { std::env::set_var("CEREBRAS_API_KEY", &v); } } } // Storage config can also reference these env vars let _ = cfg; // cfg itself doesn't need modification, env vars are set } fn load_uncached(path: &Path) -> Result<ConfigLoadArtifacts> { let mut visited = Vec::new(); let mut watched_paths = Vec::new(); let config = load_with_includes(path, &mut visited, &mut watched_paths)?; Ok(ConfigLoadArtifacts { config, watched_paths, }) } fn load_sibling_secrets(cfg: &mut Config, path: &Path) { if let Some(parent) = path.parent() { let secrets_path = parent.join("secrets.toml"); if secrets_path.exists() { if let Ok(secrets) = load_secrets(&secrets_path) { merge_secrets(cfg, secrets); tracing::debug!(path = %secrets_path.display(), "loaded secrets file"); } } } } fn load_with_includes( path: &Path, visited: &mut Vec<PathBuf>, watched_paths: &mut Vec<PathBuf>, ) -> Result<Config> { let canonical = path .canonicalize() .with_context(|| format!("failed to resolve path {}", path.display()))?; if visited.contains(&canonical) { anyhow::bail!( "cycle detected while loading config includes: {}", path.display() ); } visited.push(canonical.clone()); watched_paths.push(canonical.clone()); let contents = fs::read_to_string(&canonical) .with_context(|| format!("failed to read flow config at {}", path.display()))?; let mut cfg: Config = match toml::from_str(&contents) { Ok(cfg) => cfg, Err(err) => { let fix = fixup::fix_toml_content(&contents); if fix.fixes_applied.is_empty() { return Err(err) .with_context(|| format!("failed to parse flow config at {}", path.display())); } let fixed = fixup::apply_fixes_to_content(&contents, &fix.fixes_applied); if let Err(write_err) = fs::write(&canonical, &fixed) { return Err(err) .with_context(|| format!("failed to parse flow config at {}", path.display())) .with_context(|| format!("auto-fix write failed: {}", write_err)); } toml::from_str(&fixed).with_context(|| { format!( "failed to parse flow config at {} (after auto-fix)", path.display() ) })? } }; for include in cfg.command_files.clone() { let include_path = resolve_include_path(&canonical, &include.path); if let Some(description) = include.description.as_deref() { tracing::debug!( path = %include_path.display(), description, "loading additional command file" ); } let included = load_with_includes(&include_path, visited, watched_paths) .with_context(|| format!("failed to load commands file {}", include_path.display()))?; merge_config(&mut cfg, included); } visited.pop(); Ok(cfg) } fn config_cache_disabled() -> bool { matches!( std::env::var(CONFIG_CACHE_ENV_DISABLE) .ok() .as_deref() .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), Some("1" | "true" | "yes" | "on") ) } fn config_cache_path(path: &Path) -> PathBuf { let hash = blake3::hash(path.to_string_lossy().as_bytes()).to_hex(); global_state_dir() .join("config-cache") .join(format!("{hash}.msgpack")) } fn read_config_cache(path: &Path) -> Option<ConfigCacheEntry> { let bytes = fs::read(path).ok()?; let cache = rmp_serde::from_slice::<ConfigCacheEntry>(&bytes).ok()?; if cache.version != CONFIG_CACHE_VERSION { return None; } Some(cache) } fn write_config_cache(path: &Path, cache: &ConfigCacheEntry) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create config cache dir {}", parent.display()))?; } let bytes = rmp_serde::to_vec(cache).context("failed to encode config cache")?; let tmp_path = path.with_extension(format!("msgpack.tmp.{}", std::process::id())); fs::write(&tmp_path, bytes) .with_context(|| format!("failed to write config cache {}", tmp_path.display()))?; if let Err(err) = fs::rename(&tmp_path, path) { if path.exists() { let _ = fs::remove_file(path); fs::rename(&tmp_path, path) .with_context(|| format!("failed to finalize config cache {}", path.display()))?; } else { return Err(err) .with_context(|| format!("failed to finalize config cache {}", path.display())); } } Ok(()) } fn config_stamps_for_paths(paths: &[PathBuf]) -> Vec<ConfigPathStamp> { let mut stamps: Vec<ConfigPathStamp> = paths .iter() .filter_map(|path| ConfigPathStamp::capture(path)) .collect(); stamps.sort_by(|a, b| a.path.cmp(&b.path)); stamps.dedup_by(|a, b| a.path == b.path); stamps } fn config_stamps_match(stamps: &[ConfigPathStamp]) -> bool { stamps.iter().all(ConfigPathStamp::matches_current) } impl ConfigPathStamp { fn capture(path: &Path) -> Option<Self> { let metadata = fs::metadata(path).ok()?; let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?; Some(Self { path: path.to_path_buf(), is_dir: metadata.is_dir(), len: metadata.len(), modified_sec: modified.as_secs(), modified_nsec: modified.subsec_nanos(), }) } fn matches_current(&self) -> bool { let Some(current) = Self::capture(&self.path) else { return false; }; self.is_dir == current.is_dir && self.len == current.len && self.modified_sec == current.modified_sec && self.modified_nsec == current.modified_nsec } } pub(crate) fn resolve_include_path(base: &Path, include: &str) -> PathBuf { let include_path = PathBuf::from(include); if include_path.is_absolute() { include_path } else if let Some(parent) = base.parent() { parent.join(include_path) } else { include_path } } fn merge_config(base: &mut Config, other: Config) { if base.project_name.is_none() { base.project_name = other.project_name; } if base.flow.primary_task.is_none() { base.flow.primary_task = other.flow.primary_task; } if base.flow.release_task.is_none() { base.flow.release_task = other.flow.release_task; } if base.flow.deploy_task.is_none() { base.flow.deploy_task = other.flow.deploy_task; } if base.codex.is_none() { base.codex = other.codex; } else if let (Some(base_codex), Some(other_codex)) = (base.codex.as_mut(), other.codex) { base_codex.merge(other_codex); } merge_release_config(base, other.release); if base.setup.is_none() { base.setup = other.setup; } else if let (Some(base_setup), Some(other_setup)) = (base.setup.as_mut(), other.setup) { if base_setup.server.is_none() { base_setup.server = other_setup.server; } else if let (Some(base_server), Some(other_server)) = (base_setup.server.as_mut(), other_setup.server) { if base_server.template.is_none() { base_server.template = other_server.template; } if base_server.host.is_none() { base_server.host = other_server.host; } } } if base.task_resolution.is_none() { base.task_resolution = other.task_resolution; } else if let (Some(base_resolution), Some(other_resolution)) = (base.task_resolution.as_mut(), other.task_resolution) { base_resolution.merge(other_resolution); } if base.analytics.is_none() { base.analytics = other.analytics; } if base.git.is_none() { base.git = other.git; } if base.jj.is_none() { base.jj = other.jj; } if base.everruns.is_none() { base.everruns = other.everruns; } base.options.merge(other.options); base.servers.extend(other.servers); base.remote_servers.extend(other.remote_servers); base.tasks.extend(other.tasks); base.watchers.extend(other.watchers); base.daemons.extend(other.daemons); base.stream = base.stream.take().or(other.stream); base.invariants = base.invariants.take().or(other.invariants); base.storage = base.storage.take().or(other.storage); base.server_hub = base.server_hub.take().or(other.server_hub); for (key, value) in other.aliases { base.aliases.entry(key).or_insert(value); } for (key, value) in other.dependencies { base.dependencies.entry(key).or_insert(value); } match (&mut base.flox, other.flox) { (Some(base_flox), Some(other_flox)) => { for (key, value) in other_flox.install { base_flox.install.entry(key).or_insert(value); } } (None, Some(other_flox)) => base.flox = Some(other_flox), _ => {} } } fn merge_release_config(base: &mut Config, other: Option<ReleaseConfig>) { let Some(other) = other else { return; }; let base_release = base.release.get_or_insert_with(ReleaseConfig::default); if base_release.default.is_none() { base_release.default = other.default; } if base_release.domain.is_none() { base_release.domain = other.domain; } if base_release.base_url.is_none() { base_release.base_url = other.base_url; } if base_release.root.is_none() { base_release.root = other.root; } if base_release.caddyfile.is_none() { base_release.caddyfile = other.caddyfile; } if base_release.readme.is_none() { base_release.readme = other.readme; } if let Some(other_registry) = other.registry { let registry = base_release .registry .get_or_insert_with(RegistryReleaseConfig::default); if registry.url.is_none() { registry.url = other_registry.url; } if registry.package.is_none() { registry.package = other_registry.package; } if registry.bins.is_none() { registry.bins = other_registry.bins; } if registry.default_bin.is_none() { registry.default_bin = other_registry.default_bin; } if registry.token_env.is_none() { registry.token_env = other_registry.token_env; } if registry.latest.is_none() { registry.latest = other_registry.latest; } } } fn first_non_empty_remote(value: Option<&str>) -> Option<String> { let trimmed = value.map(str::trim).unwrap_or_default(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn preferred_git_remote_from_cfg(cfg: &Config) -> Option<String> { if let Some(remote) = cfg .git .as_ref() .and_then(|git_cfg| first_non_empty_remote(git_cfg.remote.as_deref())) { return Some(remote); } // Backward compatibility: honor jj.remote when git.remote is not set. cfg.jj .as_ref() .and_then(|jj_cfg| first_non_empty_remote(jj_cfg.remote.as_deref())) } /// Resolve the preferred writable git remote for a repository. /// /// Precedence: /// 1. `<repo>/flow.toml` `[git].remote` /// 2. `<repo>/flow.toml` `[jj].remote` (legacy fallback) /// 3. `~/.config/flow/flow.toml` `[git].remote` /// 4. `~/.config/flow/flow.toml` `[jj].remote` (legacy fallback) /// 5. `"origin"` pub fn preferred_git_remote_for_repo(repo_root: &Path) -> String { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = load(&local_config) { if let Some(remote) = preferred_git_remote_from_cfg(&cfg) { return remote; } } } let global_config = default_config_path(); if global_config.exists() { if let Ok(cfg) = load(&global_config) { if let Some(remote) = preferred_git_remote_from_cfg(&cfg) { return remote; } } } "origin".to_string() } /// Load config from the given path, logging a warning and returning an empty /// config if anything goes wrong. This keeps the daemon usable even if the /// config file is missing or invalid. pub fn load_or_default<P: AsRef<Path>>(path: P) -> Config { match load(path) { Ok(cfg) => cfg, Err(err) => { tracing::warn!( ?err, "failed to load flow config; starting with no managed servers" ); Config::default() } } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use tempfile::tempdir; fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative) } #[test] fn load_parses_global_fixture() { let cfg = load(fixture_path("test-data/global-config/flow.toml")) .expect("global config fixture should parse"); assert_eq!(cfg.version, Some(1)); assert!(cfg.options.trace_terminal_io, "options table should parse"); assert_eq!(cfg.servers.len(), 1); assert_eq!(cfg.remote_servers.len(), 1); assert_eq!(cfg.watchers.len(), 1); assert_eq!( cfg.tasks.len(), 1, "global config should inherit tasks from included command files" ); let watcher = &cfg.watchers[0]; assert_eq!(watcher.driver, WatcherDriver::Shell); assert_eq!(watcher.name, "karabiner"); assert_eq!(watcher.path, "~/config/i/karabiner"); assert_eq!(watcher.filter.as_deref(), Some("karabiner.edn")); assert_eq!(watcher.command.as_deref(), Some("~/bin/goku")); assert_eq!(watcher.debounce_ms, 150); assert!(watcher.run_on_start); assert!(watcher.poltergeist.is_none()); let server = &cfg.servers[0]; assert_eq!(server.name, "cloud"); assert_eq!(server.command, "blade"); assert_eq!(server.args, ["--port", "4000"]); let working_dir = server .working_dir .as_ref() .expect("server working dir should parse"); assert!( working_dir.ends_with("code/myflow"), "unexpected working dir: {}", working_dir.display() ); assert!(server.env.is_empty()); assert!( server.autostart, "autostart should default to true when omitted" ); let sync_task = &cfg.tasks[0]; assert_eq!(sync_task.name, "sync-config"); assert_eq!( sync_task.command, "rsync -av ~/.config/flow remote:~/flow-config" ); assert!( cfg.aliases.contains_key("fsh"), "included aliases should merge into base config" ); let remote = &cfg.remote_servers[0]; assert_eq!(remote.server.name, "homelab-blade"); assert_eq!(remote.hub.as_deref(), Some("homelab")); assert_eq!(remote.sync_paths, [PathBuf::from("~/config/i/karabiner")]); let hub = cfg.server_hub.as_ref().expect("server hub config"); assert_eq!(hub.name, "homelab"); assert_eq!(hub.host, "tailscale"); assert_eq!(hub.port, 9050); assert_eq!(hub.tailscale.as_deref(), Some("linux-hub")); } #[test] fn server_port_is_preserved_when_present() { let toml = r#" [[server]] name = "api" command = "npm start" port = 8080 "#; let cfg: Config = toml::from_str(toml).expect("server config should parse"); let server = cfg.servers.first().expect("server should parse"); assert_eq!(server.port, Some(8080)); // Missing port should deserialize as None for backward compatibility. let no_port_toml = r#" [[server]] name = "web" command = "npm run dev" "#; let cfg: Config = toml::from_str(no_port_toml).expect("server config without port should parse"); assert_eq!(cfg.servers[0].port, None); } #[test] fn expand_path_supports_tilde_and_env() { let home = std::env::var("HOME").expect("HOME must be set for tests"); let expected = PathBuf::from(&home).join("projects/demo"); assert_eq!(expand_path("~/projects/demo"), expected); assert_eq!(expand_path("$HOME/projects/demo"), expected); } #[test] fn lifecycle_domains_aliases_parse() { let toml = r#" [lifecycle] up_task = "dev" [lifecycle.domains] host = "myflow.localhost" target = "127.0.0.1:3000" engine = "native" [[lifecycle.domains.aliases]] host = "api.myflow.localhost" target = "127.0.0.1:8780" "#; let cfg: Config = toml::from_str(toml).expect("lifecycle domains alias config should parse"); let lifecycle = cfg.lifecycle.expect("lifecycle should parse"); let domains = lifecycle.domains.expect("domains should parse"); assert_eq!(domains.host.as_deref(), Some("myflow.localhost")); assert_eq!(domains.target.as_deref(), Some("127.0.0.1:3000")); assert_eq!(domains.aliases.len(), 1); assert_eq!( domains.aliases[0].host.as_deref(), Some("api.myflow.localhost") ); assert_eq!(domains.aliases[0].target.as_deref(), Some("127.0.0.1:8780")); } #[test] fn global_state_dir_prefers_config_dir_for_fresh_homes() { let dir = tempdir().expect("tempdir"); let config_dir = dir.path().join(".config/flow"); assert_eq!(select_global_state_dir(&config_dir), config_dir); } #[test] fn global_state_dir_preserves_legacy_state_root_when_present() { let dir = tempdir().expect("tempdir"); let config_dir = dir.path().join(".config/flow"); let legacy_dir = dir.path().join(".config/flow-state"); fs::create_dir_all(&legacy_dir).expect("legacy dir"); assert_eq!(select_global_state_dir(&config_dir), legacy_dir); } #[test] fn global_state_dir_falls_back_when_config_path_is_blocked() { let dir = tempdir().expect("tempdir"); let config_dir = dir.path().join(".config/flow"); fs::create_dir_all(config_dir.parent().expect("config parent")).expect("parent dir"); fs::write(&config_dir, "blocked").expect("blocking file"); assert_eq!( select_global_state_dir(&config_dir), dir.path().join(".config/flow-state") ); } #[test] fn parses_poltergeist_watcher() { let toml = r#" [[watchers]] driver = "poltergeist" name = "peekaboo" path = "~/code/myflow/peekaboo" [watchers.poltergeist] binary = "/opt/bin/poltergeist" mode = "panel" args = ["status", "--verbose"] "#; let cfg: Config = toml::from_str(toml).expect("poltergeist watcher should parse"); assert_eq!(cfg.watchers.len(), 1); let watcher = &cfg.watchers[0]; assert_eq!(watcher.driver, WatcherDriver::Poltergeist); assert_eq!(watcher.command, None); assert_eq!(watcher.path, "~/code/myflow/peekaboo"); let poltergeist = watcher .poltergeist .as_ref() .expect("poltergeist config should exist"); assert_eq!(poltergeist.binary, "/opt/bin/poltergeist"); assert_eq!(poltergeist.mode, PoltergeistMode::Panel); assert_eq!(poltergeist.args, vec!["status", "--verbose"]); } #[test] fn load_or_default_returns_empty_when_missing() { let missing_path = fixture_path("test-data/global-config/does-not-exist.toml"); let cfg = load_or_default(missing_path); assert!( cfg.servers.is_empty(), "missing config should fall back to empty server list" ); } #[test] fn load_parses_project_tasks() { let cfg = load(fixture_path("test-data/simple-project/flow.toml")) .expect("simple project config should parse"); assert!(cfg.servers.is_empty(), "project fixture focuses on tasks"); assert_eq!(cfg.tasks.len(), 2); let lint = &cfg.tasks[0]; assert_eq!(lint.name, "lint"); assert_eq!(lint.command, "golangci-lint run"); assert_eq!( lint.description.as_deref(), Some("Run static analysis for Go sources") ); let test_task = &cfg.tasks[1]; assert_eq!(test_task.name, "test"); assert_eq!(test_task.command, "gotestsum -f pkgname ./..."); assert_eq!( test_task.description.as_deref(), Some("Execute the Go test suite with gotestsum output"), "desc alias should populate description" ); } #[test] fn load_parses_dependency_table() { let contents = r#" [dependencies] fast = "fast" toolkit = ["rg", "fd"] [[tasks]] name = "ci" command = "ci" dependencies = ["fast", "toolkit"] "#; let cfg: Config = toml::from_str(contents).expect("inline config with dependencies should parse"); let task = cfg.tasks.first().expect("task should parse"); assert_eq!( task.dependencies, ["fast", "toolkit"], "task dependency references should parse" ); let fast = cfg .dependencies .get("fast") .expect("fast dependency should be present"); match fast { DependencySpec::Single(expr) => { assert_eq!(expr, "fast"); } other => panic!("fast dependency variant mismatch: {other:?}"), } let toolkit = cfg .dependencies .get("toolkit") .expect("toolkit dependency should be present"); match toolkit { DependencySpec::Multiple(exprs) => { assert_eq!(exprs, &["rg", "fd"]); } other => panic!("toolkit dependency variant mismatch: {other:?}"), } } #[test] fn parses_flox_dependencies_and_config() { let contents = r#" [dependencies] rg.pkg-path = "ripgrep" [flox.deps] fd.pkg-path = "fd" "#; let cfg: Config = toml::from_str(contents).expect("config with flox deps should parse"); match cfg.dependencies.get("rg") { Some(DependencySpec::Flox(spec)) => { assert_eq!(spec.pkg_path, "ripgrep"); } other => panic!("unexpected dependency variant: {other:?}"), } let flox = cfg.flox.expect("flox config should exist"); let fd = flox .install .get("fd") .expect("fd install should be present"); assert_eq!(fd.pkg_path, "fd"); } #[test] fn task_activation_flag_defaults_and_parses() { let toml = r#" [[tasks]] name = "lint" command = "golangci-lint run" [[tasks]] name = "setup" command = "cargo check" activate_on_cd_to_root = true "#; let cfg: Config = toml::from_str(toml).expect("activation config should parse"); assert_eq!(cfg.tasks.len(), 2); assert!(!cfg.tasks[0].activate_on_cd_to_root); assert!(cfg.tasks[1].activate_on_cd_to_root); } #[test] fn load_parses_aliases() { let contents = r#" [aliases] fr = "f run" ls = "f tasks" "#; let cfg: Config = toml::from_str(contents).expect("inline alias config should parse"); assert_eq!(cfg.aliases.get("fr").map(String::as_str), Some("f run")); assert_eq!(cfg.aliases.get("ls").map(String::as_str), Some("f tasks")); } #[test] fn load_parses_alias_array_table() { let contents = r#" [[alias]] fr = "f run" fc = "f commit" [[alias]] dev = "f run dev" "#; let cfg: Config = toml::from_str(contents).expect("alias array config should parse"); assert_eq!(cfg.aliases.get("fr").map(String::as_str), Some("f run")); assert_eq!(cfg.aliases.get("fc").map(String::as_str), Some("f commit")); assert_eq!( cfg.aliases.get("dev").map(String::as_str), Some("f run dev") ); } #[test] fn options_defaults_are_false() { let cfg: Config = toml::from_str("").expect("empty config should parse with default options"); assert!(!cfg.options.trace_terminal_io); assert!(cfg.options.commit_with_check_async.is_none()); assert!(cfg.options.commit_with_check_use_repo_root.is_none()); assert!(cfg.options.commit_with_check_timeout_secs.is_none()); assert!(cfg.options.commit_with_check_gitedit_mirror.is_none()); } #[test] fn options_trace_flag_parses() { let toml = r#" [options] trace_terminal_io = true "#; let cfg: Config = toml::from_str(toml).expect("options table should parse"); assert!(cfg.options.trace_terminal_io); } #[test] fn options_commit_with_check_timeout_parses() { let toml = r#" [options] commit_with_check_timeout_secs = 120 "#; let cfg: Config = toml::from_str(toml).expect("options table should parse"); assert_eq!(cfg.options.commit_with_check_timeout_secs, Some(120)); } #[test] fn options_commit_with_check_review_retries_parses() { let toml = r#" [options] commit_with_check_review_retries = 3 "#; let cfg: Config = toml::from_str(toml).expect("options table should parse"); assert_eq!(cfg.options.commit_with_check_review_retries, Some(3)); } #[test] fn options_commit_with_check_async_parses() { let toml = r#" [options] commit_with_check_async = false "#; let cfg: Config = toml::from_str(toml).expect("options table should parse"); assert_eq!(cfg.options.commit_with_check_async, Some(false)); } #[test] fn options_commit_with_check_use_repo_root_parses() { let toml = r#" [options] commit_with_check_use_repo_root = false "#; let cfg: Config = toml::from_str(toml).expect("options table should parse"); assert_eq!(cfg.options.commit_with_check_use_repo_root, Some(false)); } #[test] fn options_commit_with_check_gitedit_mirror_parses() { let toml = r#" [options] commit_with_check_gitedit_mirror = true "#; let cfg: Config = toml::from_str(toml).expect("options table should parse"); assert_eq!(cfg.options.commit_with_check_gitedit_mirror, Some(true)); } #[test] fn options_codex_bin_parses() { let toml = r#" [options] codex_bin = "/tmp/codex-jazz" "#; let cfg: Config = toml::from_str(toml).expect("options table should parse"); assert_eq!(cfg.options.codex_bin.as_deref(), Some("/tmp/codex-jazz")); } #[test] fn task_resolution_config_parses() { let toml = r#" [task_resolution] preferred_scopes = ["mobile", "root"] warn_on_implicit_scope = true [task_resolution.routes] dev = "mobile" test = "root" "#; let cfg: Config = toml::from_str(toml).expect("task_resolution should parse"); let resolution = cfg .task_resolution .expect("task_resolution config expected"); assert_eq!( resolution.preferred_scopes, vec!["mobile".to_string(), "root".to_string()] ); assert_eq!(resolution.warn_on_implicit_scope, Some(true)); assert_eq!( resolution.routes.get("dev").map(String::as_str), Some("mobile") ); assert_eq!( resolution.routes.get("test").map(String::as_str), Some("root") ); } #[test] fn commit_testing_config_parses() { let toml = r#" [commit.testing] mode = "block" runner = "bun" bun_repo_strict = true require_related_tests = true ai_scratch_test_dir = ".ai/test" run_ai_scratch_tests = true allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 20 "#; let cfg: Config = toml::from_str(toml).expect("commit.testing should parse"); let commit = cfg.commit.expect("commit config expected"); let testing = commit.testing.expect("testing config expected"); assert_eq!(testing.mode.as_deref(), Some("block")); assert_eq!(testing.runner.as_deref(), Some("bun")); assert_eq!(testing.bun_repo_strict, Some(true)); assert_eq!(testing.require_related_tests, Some(true)); assert_eq!(testing.ai_scratch_test_dir.as_deref(), Some(".ai/test")); assert_eq!(testing.run_ai_scratch_tests, Some(true)); assert_eq!(testing.allow_ai_scratch_to_satisfy_gate, Some(false)); assert_eq!(testing.max_local_gate_seconds, Some(20)); } #[test] fn commit_quick_default_parses() { let toml = r#" [commit] quick-default = true "#; let cfg: Config = toml::from_str(toml).expect("commit config should parse"); let commit = cfg.commit.expect("commit config expected"); assert_eq!(commit.quick_default, Some(true)); } #[test] fn commit_skill_gate_config_parses() { let toml = r#" [commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] quality-bun-feature-delivery = 2 "#; let cfg: Config = toml::from_str(toml).expect("commit.skill_gate should parse"); let commit = cfg.commit.expect("commit config expected"); let skill_gate = commit.skill_gate.expect("skill gate config expected"); assert_eq!(skill_gate.mode.as_deref(), Some("block")); assert_eq!( skill_gate.required, vec!["quality-bun-feature-delivery".to_string()] ); let min_version = skill_gate.min_version.expect("min_version map expected"); assert_eq!(min_version.get("quality-bun-feature-delivery"), Some(&2)); } #[test] fn skills_codex_config_parses() { let toml = r#" [skills] sync_tasks = true install = ["quality-bun-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false "#; let cfg: Config = toml::from_str(toml).expect("skills.codex should parse"); let skills = cfg.skills.expect("skills config expected"); assert!(skills.sync_tasks); assert_eq!( skills.install, vec!["quality-bun-feature-delivery".to_string()] ); let codex = skills.codex.expect("skills.codex expected"); assert_eq!(codex.generate_openai_yaml, Some(true)); assert_eq!(codex.force_reload_after_sync, Some(true)); assert_eq!(codex.task_skill_allow_implicit_invocation, Some(false)); } #[test] fn codex_reference_resolver_config_parses() { let toml = r#" [codex] auto_resolve_references = true runtime_skills = true home_session_path = "~/repos/openai/codex" prompt_context_budget_chars = 900 max_resolved_references = 1 [[codex.reference_resolver]] name = "linear" match = ["https://linear.app/*/issue/*", "https://linear.app/*/project/*"] command = "my-linear-tool inspect {{ref}} --json" inject_as = "linear" "#; let cfg: Config = toml::from_str(toml).expect("codex config should parse"); let codex = cfg.codex.expect("codex config expected"); assert_eq!(codex.auto_resolve_references, Some(true)); assert_eq!(codex.runtime_skills, Some(true)); assert_eq!( codex.home_session_path.as_deref(), Some("~/repos/openai/codex") ); assert_eq!(codex.prompt_context_budget_chars, Some(900)); assert_eq!(codex.max_resolved_references, Some(1)); assert_eq!(codex.reference_resolvers.len(), 1); let resolver = &codex.reference_resolvers[0]; assert_eq!(resolver.name, "linear"); assert_eq!( resolver.matches, vec![ "https://linear.app/*/issue/*".to_string(), "https://linear.app/*/project/*".to_string(), ] ); assert_eq!(resolver.command, "my-linear-tool inspect {{ref}} --json"); assert_eq!(resolver.inject_as.as_deref(), Some("linear")); } #[test] fn codex_skill_source_config_parses() { let toml = r#" [codex] [[codex.skill_source]] name = "vercel-labs-skills" path = "~/repos/vercel-labs/skills" enabled = true "#; let cfg: Config = toml::from_str(toml).expect("codex skill_source should parse"); let codex = cfg.codex.expect("codex config expected"); assert_eq!(codex.skill_sources.len(), 1); let source = &codex.skill_sources[0]; assert_eq!(source.name, "vercel-labs-skills"); assert_eq!(source.path, "~/repos/vercel-labs/skills"); assert_eq!(source.enabled, Some(true)); } #[test] fn git_remote_config_parses() { let toml = r#" [git] remote = "myflow-i" "#; let cfg: Config = toml::from_str(toml).expect("git config should parse"); let git = cfg.git.expect("git config expected"); assert_eq!(git.remote.as_deref(), Some("myflow-i")); } #[test] fn preferred_git_remote_prefers_git_then_jj() { let mut cfg = Config::default(); cfg.jj = Some(JjConfig { remote: Some("jj-remote".to_string()), ..Default::default() }); assert_eq!( preferred_git_remote_from_cfg(&cfg).as_deref(), Some("jj-remote") ); cfg.git = Some(GitConfig { remote: Some("git-remote".to_string()), ..Default::default() }); assert_eq!( preferred_git_remote_from_cfg(&cfg).as_deref(), Some("git-remote") ); } #[test] fn analytics_config_parses() { let toml = r#" [analytics] enabled = true endpoint = "http://127.0.0.1:7331/v1/trace" sample_rate = 0.5 "#; let cfg: Config = toml::from_str(toml).expect("analytics config should parse"); let analytics = cfg.analytics.expect("analytics config expected"); assert_eq!(analytics.enabled, Some(true)); assert_eq!( analytics.endpoint.as_deref(), Some("http://127.0.0.1:7331/v1/trace") ); assert_eq!(analytics.sample_rate, Some(0.5)); } } ================================================ FILE: src/daemon.rs ================================================ //! Generic daemon management for flow. //! //! Allows starting, stopping, and monitoring background daemons defined in flow.toml. use std::{ fs, fs::OpenOptions, path::{Path, PathBuf}, process::Command, time::Duration, }; use anyhow::{Context, Result, bail}; use regex::Regex; use reqwest::blocking::Client; use crate::{ cli::{DaemonAction, DaemonCommand}, codexd, config::{self, DaemonConfig, DaemonRestartPolicy}, env, supervisor, }; /// Run the daemon command. pub fn run(cmd: DaemonCommand) -> Result<()> { let action = cmd.action.unwrap_or(DaemonAction::Status { name: None }); let config_path = resolve_flow_toml_path(); if supervisor::try_handle_daemon_action(&action, config_path.as_deref())? { return Ok(()); } match action { DaemonAction::Start { name } => start_daemon(&name)?, DaemonAction::Stop { name } => stop_daemon(&name)?, DaemonAction::Restart { name } => { stop_daemon(&name).ok(); std::thread::sleep(Duration::from_millis(500)); start_daemon(&name)?; } DaemonAction::Status { name } => { if let Some(name) = name { show_status_for(&name)?; } else { show_status()?; } } DaemonAction::List => list_daemons()?, } Ok(()) } /// Start a daemon by name. pub fn start_daemon(name: &str) -> Result<()> { start_daemon_with_path(name, resolve_flow_toml_path().as_deref()) } pub fn start_daemon_with_path(name: &str, config_path: Option<&Path>) -> Result<()> { let daemon = find_daemon_config_with_path(name, config_path)?; start_daemon_inner(&daemon) } fn start_daemon_inner(daemon: &DaemonConfig) -> Result<()> { // Check if already running if daemon_health_status(daemon) == Some(true) { println!("✓ {} is already running", daemon.name); return Ok(()); } // Check if there's a stale PID if let Some(pid) = load_daemon_pid(&daemon.name)? { if process_alive(pid)? { terminate_process(pid).ok(); } remove_daemon_pid(&daemon.name).ok(); } // Evict any foreign process squatting on this daemon's port if let Some(port) = daemon.port { kill_process_on_port(port).ok(); } else if let Some(url) = daemon.effective_health_url() { if let Some(port) = extract_port_from_url(&url) { kill_process_on_port(port).ok(); } } // Find the binary let binary = find_binary(&daemon.binary)?; println!( "Starting {} using {}{}", daemon.name, binary.display(), daemon .command .as_ref() .map(|c| format!(" {}", c)) .unwrap_or_default() ); let spawned = spawn_daemon_process(daemon, &binary)?; persist_daemon_pid(&daemon.name, spawned.pid)?; // Wait a moment and check health wait_for_daemon_ready(daemon, &spawned.stdout_log)?; match daemon_health_status(daemon) { Some(true) => println!("✓ {} started successfully", daemon.name), Some(false) => println!( "⚠ {} started but health check failed (may need more time)", daemon.name ), None => println!("✓ {} started (no health check configured)", daemon.name), } Ok(()) } /// Stop a daemon by name. pub fn stop_daemon(name: &str) -> Result<()> { stop_daemon_with_path(name, resolve_flow_toml_path().as_deref()) } pub fn stop_daemon_with_path(name: &str, config_path: Option<&Path>) -> Result<()> { let daemon = find_daemon_config_with_path(name, config_path).ok(); if let Some(pid) = load_daemon_pid(name)? { if process_alive(pid)? { terminate_process(pid)?; println!("✓ {} stopped (PID {})", name, pid); } else { println!("✓ {} was not running", name); } remove_daemon_pid(name).ok(); } else { println!("✓ {} was not running (no PID file)", name); } // Also try to kill any process listening on the daemon's port // This handles cases where child processes outlive the parent if let Some(daemon) = daemon { if let Some(port) = daemon.port { kill_process_on_port(port).ok(); } else if let Some(url) = &daemon.health_url { if let Some(port) = extract_port_from_url(url) { kill_process_on_port(port).ok(); } } } Ok(()) } /// Show status of all configured daemons. pub fn show_status() -> Result<()> { show_status_with_path(resolve_flow_toml_path().as_deref()) } /// Show status of a specific daemon. pub fn show_status_for(name: &str) -> Result<()> { show_status_for_with_path(name, resolve_flow_toml_path().as_deref()) } pub fn show_status_with_path(config_path: Option<&Path>) -> Result<()> { let config = load_merged_config_with_path(config_path)?; if config.daemons.is_empty() { println!("No daemons configured."); println!(); println!("Add daemons to ~/.config/flow/flow.toml or project flow.toml:"); println!(); println!(" [[daemon]]"); println!(" name = \"my-daemon\""); println!(" binary = \"my-app\""); println!(" command = \"serve\""); println!(" health_url = \"http://127.0.0.1:8080/health\""); return Ok(()); } println!("Daemon Status:"); println!(); for daemon in &config.daemons { let status = get_daemon_status(&daemon); let icon = if status.running { "✓" } else { "✗" }; let state = if status.running { "running" } else { "stopped" }; print!(" {} {}: {}", icon, daemon.name, state); if let Some(target) = daemon.health_target_label() { if status.running { print!(" ({})", target); } } if let Some(pid) = status.pid { print!(" [PID {}]", pid); } println!(); if let Some(desc) = &daemon.description { println!(" {}", desc); } } Ok(()) } pub fn show_status_for_with_path(name: &str, config_path: Option<&Path>) -> Result<()> { let daemon = find_daemon_config_with_path(name, config_path)?; let status = get_daemon_status(&daemon); let icon = if status.running { "✓" } else { "✗" }; let state = if status.running { "running" } else { "stopped" }; println!("Daemon Status:"); println!(); print!(" {} {}: {}", icon, daemon.name, state); if let Some(target) = daemon.health_target_label() { if status.running { if status.healthy == Some(false) { print!(" (unhealthy: {})", target); } else { print!(" ({})", target); } } } if let Some(pid) = status.pid { print!(" [PID {}]", pid); } println!(); if let Some(desc) = &daemon.description { println!(" {}", desc); } Ok(()) } /// List available daemons. pub fn list_daemons() -> Result<()> { list_daemons_with_path(resolve_flow_toml_path().as_deref()) } pub fn list_daemons_with_path(config_path: Option<&Path>) -> Result<()> { let config = load_merged_config_with_path(config_path)?; if config.daemons.is_empty() { println!("No daemons configured."); return Ok(()); } println!("Available daemons:"); println!(); for daemon in &config.daemons { print!(" {}", daemon.name); if let Some(desc) = &daemon.description { print!(" - {}", desc); } println!(); } Ok(()) } /// Status of a daemon. #[derive(Debug)] pub struct DaemonStatus { pub running: bool, pub pid: Option<u32>, pub healthy: Option<bool>, } /// Get the status of a specific daemon. pub fn get_daemon_status(daemon: &DaemonConfig) -> DaemonStatus { let pid = load_daemon_pid(&daemon.name).ok().flatten(); let pid_alive = pid .map(|pid| process_alive(pid).unwrap_or(false)) .unwrap_or(false); let healthy = daemon_health_status(daemon); let running = if healthy.is_some() { // Prefer PID when available; a transient health blip shouldn't mark the process as stopped. if pid.is_some() { pid_alive } else { healthy.unwrap_or(false) } } else { pid_alive }; DaemonStatus { running, pid, healthy, } } pub fn restart_policy_for(daemon: &DaemonConfig) -> DaemonRestartPolicy { match daemon.restart { Some(ref policy) => policy.clone(), None => { if daemon.retry.unwrap_or(0) > 0 { DaemonRestartPolicy::OnFailure } else { DaemonRestartPolicy::Never } } } } pub fn daemon_log_dir(name: &str) -> Result<PathBuf> { let base = config::ensure_global_state_dir()?; let dir = base.join("daemons").join(sanitize_daemon_name(name)); fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; Ok(dir) } pub fn daemon_log_paths(name: &str) -> Result<(PathBuf, PathBuf)> { let dir = daemon_log_dir(name)?; Ok((dir.join("stdout.log"), dir.join("stderr.log"))) } fn sanitize_daemon_name(name: &str) -> String { let mut out = String::new(); for ch in name.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { out.push(ch); } else { out.push('_'); } } if out.is_empty() { "daemon".to_string() } else { out } } struct SpawnedDaemon { pid: u32, stdout_log: PathBuf, } fn spawn_daemon_process(daemon: &DaemonConfig, binary: &Path) -> Result<SpawnedDaemon> { let mut cmd = Command::new(binary); if let Some(subcommand) = &daemon.command { cmd.arg(subcommand); } for arg in &daemon.args { cmd.arg(arg); } if let Some(wd) = &daemon.working_dir { let expanded = config::expand_path(wd); if expanded.exists() { cmd.current_dir(&expanded); } } if let Ok(vars) = env::fetch_local_personal_env_vars(&config::global_env_keys()) { for (key, value) in vars { cmd.env(key, value); } } for (key, value) in &daemon.env { cmd.env(key, value); } let (stdout_log, stderr_log) = daemon_log_paths(&daemon.name)?; let stdout_file = OpenOptions::new() .create(true) .append(true) .open(&stdout_log) .with_context(|| format!("failed to open {}", stdout_log.display()))?; let stderr_file = OpenOptions::new() .create(true) .append(true) .open(&stderr_log) .with_context(|| format!("failed to open {}", stderr_log.display()))?; cmd.stdin(std::process::Stdio::null()) .stdout(stdout_file) .stderr(stderr_file); #[cfg(unix)] { use std::os::unix::process::CommandExt; cmd.process_group(0); } let child = cmd .spawn() .with_context(|| format!("failed to start {} from {}", daemon.name, binary.display()))?; Ok(SpawnedDaemon { pid: child.id(), stdout_log, }) } fn wait_for_daemon_ready(daemon: &DaemonConfig, stdout_log: &Path) -> Result<()> { if let Some(delay) = daemon.ready_delay { std::thread::sleep(Duration::from_millis(delay)); } else { std::thread::sleep(Duration::from_millis(500)); } let timeout = Duration::from_secs(30); let start = std::time::Instant::now(); let mut seen_len = 0usize; let mut ready_output_matched = daemon.ready_output.is_none(); let regex = match daemon.ready_output.as_ref() { Some(pattern) => Some(Regex::new(pattern).with_context(|| "invalid ready_output regex")?), None => None, }; while start.elapsed() < timeout { if let Some(regex) = regex.as_ref() { if let Ok(contents) = fs::read_to_string(stdout_log) { if contents.len() > seen_len { let slice = &contents[seen_len..]; if regex.is_match(slice) { ready_output_matched = true; } seen_len = contents.len(); } } } if ready_output_matched { match daemon_health_status(daemon) { Some(true) | None => return Ok(()), Some(false) => {} } } std::thread::sleep(Duration::from_millis(200)); } if let Some(pattern) = daemon.ready_output.as_ref() { if !ready_output_matched { eprintln!( "WARN ready_output '{}' not found for {} (continuing).", pattern, daemon.name ); } } Ok(()) } /// Find a daemon config by name from merged configs. fn find_daemon_config_with_path(name: &str, config_path: Option<&Path>) -> Result<DaemonConfig> { let config = load_merged_config_with_path(config_path)?; config .daemons .into_iter() .find(|d| d.name == name) .ok_or_else(|| anyhow::anyhow!("daemon '{}' not found in config", name)) } /// Load merged config from global and local sources. pub fn load_merged_config_with_path(config_path: Option<&Path>) -> Result<config::Config> { let mut merged = config::Config::default(); // Load global config let global_path = config::default_config_path(); if global_path.exists() { if let Ok(global_cfg) = config::load(&global_path) { merged.daemons.extend(global_cfg.daemons); for server in &global_cfg.servers { merged.daemons.push(server.to_daemon_config()); } } } // Load local config if it exists if let Some(local_path) = config_path { if local_path.exists() { if let Ok(local_cfg) = config::load(local_path) { merged.daemons.extend(local_cfg.daemons); for server in &local_cfg.servers { merged.daemons.push(server.to_daemon_config()); } } } } if !merged.daemons.iter().any(|daemon| daemon.name == "codexd") { if let Ok(daemon) = codexd::builtin_daemon_config() { merged.daemons.push(daemon); } } Ok(merged) } fn resolve_flow_toml_path() -> Option<PathBuf> { let mut current = std::env::current_dir().ok()?; loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } /// Find a binary on PATH or as an absolute path. fn find_binary(name: &str) -> Result<PathBuf> { // If it's an absolute path, use it directly let path = Path::new(name); if path.is_absolute() && path.exists() { return Ok(path.to_path_buf()); } // Expand ~ if present let expanded = config::expand_path(name); if expanded.exists() { return Ok(expanded); } // Try to find on PATH using `which` let output = Command::new("which") .arg(name) .output() .with_context(|| format!("failed to find binary '{}'", name))?; if output.status.success() { let path_str = String::from_utf8_lossy(&output.stdout); let path = PathBuf::from(path_str.trim()); if path.exists() { return Ok(path); } } bail!("binary '{}' not found", name) } /// Check if a health endpoint is responding. fn check_health(url: &str) -> bool { let client = Client::builder() .timeout(Duration::from_millis(750)) .build(); let Ok(client) = client else { return false; }; client .get(url) .send() .and_then(|resp| resp.error_for_status()) .map(|_| true) .unwrap_or(false) } fn check_health_socket(path: &Path) -> bool { #[cfg(unix)] { if !path.exists() { return false; } std::os::unix::net::UnixStream::connect(path).is_ok() } #[cfg(not(unix))] { let _ = path; false } } fn daemon_health_status(daemon: &DaemonConfig) -> Option<bool> { if let Some(url) = daemon.effective_health_url() { return Some(check_health(&url)); } if let Some(socket_path) = daemon.effective_health_socket() { return Some(check_health_socket(&socket_path)); } None } // ============================================================================ // PID file management // ============================================================================ fn daemon_pid_path(name: &str) -> PathBuf { if let Some(home) = std::env::var_os("HOME") { PathBuf::from(home).join(format!(".config/flow/{}.pid", name)) } else { PathBuf::from(format!(".config/flow/{}.pid", name)) } } fn load_daemon_pid(name: &str) -> Result<Option<u32>> { let path = daemon_pid_path(name); if !path.exists() { return Ok(None); } let contents = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let pid: u32 = contents.trim().parse().ok().unwrap_or(0); if pid == 0 { Ok(None) } else { Ok(Some(pid)) } } fn persist_daemon_pid(name: &str, pid: u32) -> Result<()> { let path = daemon_pid_path(name); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create pid dir {}", parent.display()))?; } fs::write(&path, pid.to_string()) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn remove_daemon_pid(name: &str) -> Result<()> { let path = daemon_pid_path(name); if path.exists() { fs::remove_file(path).ok(); } Ok(()) } // ============================================================================ // Process management // ============================================================================ fn process_alive(pid: u32) -> Result<bool> { #[cfg(unix)] { let status = Command::new("kill").arg("-0").arg(pid.to_string()).status(); return Ok(status.map(|s| s.success()).unwrap_or(false)); } #[cfg(windows)] { let output = Command::new("tasklist") .output() .context("failed to invoke tasklist")?; if !output.status.success() { return Ok(false); } let needle = pid.to_string(); let body = String::from_utf8_lossy(&output.stdout); Ok(body.lines().any(|line| line.contains(&needle))) } } fn terminate_process(pid: u32) -> Result<()> { #[cfg(unix)] { // First try to kill the process group (negative PID) // This ensures child processes are also terminated let pgid_kill = Command::new("kill") .arg(format!("-{pid}")) .stderr(std::process::Stdio::null()) .status(); // Also kill the process directly let status = Command::new("kill") .arg(format!("{pid}")) .stderr(std::process::Stdio::null()) .status() .context("failed to invoke kill command")?; // If either succeeded, we're good if status.success() || pgid_kill.map(|s| s.success()).unwrap_or(false) { return Ok(()); } bail!( "kill command exited with status {}", status.code().unwrap_or(-1) ); } #[cfg(windows)] { let status = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F", "/T"]) // /T kills child processes too .status() .context("failed to invoke taskkill")?; if status.success() { return Ok(()); } bail!( "taskkill exited with status {}", status.code().unwrap_or(-1) ); } } /// Extract port number from a URL like "http://127.0.0.1:7201/health" pub fn extract_port_from_url(url: &str) -> Option<u16> { // Simple extraction: find the port after the last colon before any path let url = url .strip_prefix("http://") .or_else(|| url.strip_prefix("https://"))?; let host_port = url.split('/').next()?; let port_str = host_port.rsplit(':').next()?; port_str.parse().ok() } /// Kill any process listening on the given port. #[cfg(unix)] pub fn kill_process_on_port(port: u16) -> Result<()> { // Use lsof to find the process let output = Command::new("lsof") .args(["-ti", &format!(":{}", port)]) .output() .context("failed to run lsof")?; if !output.status.success() { return Ok(()); // No process found on port } let pids = String::from_utf8_lossy(&output.stdout); for pid_str in pids.lines() { if let Ok(pid) = pid_str.trim().parse::<u32>() { terminate_process(pid).ok(); } } Ok(()) } #[cfg(windows)] pub fn kill_process_on_port(port: u16) -> Result<()> { // Use netstat to find the process let output = Command::new("netstat") .args(["-ano"]) .output() .context("failed to run netstat")?; if !output.status.success() { return Ok(()); } let port_pattern = format!(":{}", port); let lines = String::from_utf8_lossy(&output.stdout); for line in lines.lines() { if line.contains(&port_pattern) && line.contains("LISTENING") { // Last column is PID if let Some(pid_str) = line.split_whitespace().last() { if let Ok(pid) = pid_str.parse::<u32>() { terminate_process(pid).ok(); } } } } Ok(()) } ================================================ FILE: src/daemon_snapshot.rs ================================================ use std::path::Path; use anyhow::Result; use serde::{Deserialize, Serialize}; use crate::{activity_log, daemon, supervisor}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FlowDaemonEntry { pub name: String, pub status: String, pub running: bool, #[serde(default)] pub healthy: Option<bool>, pub pid: Option<u32>, pub health_target: Option<String>, pub description: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FlowDaemonSnapshot { pub total: usize, pub running: usize, pub healthy: usize, pub unhealthy: usize, pub stopped: usize, pub entries: Vec<FlowDaemonEntry>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FlowDaemonAction { Start, Stop, Restart, } pub fn load_daemon_snapshot(config_path: Option<&Path>) -> Result<FlowDaemonSnapshot> { let mut entries: Vec<_> = supervisor::daemon_status_views(config_path)? .into_iter() .map(|view| { let status = if !view.running { "stopped" } else if view.healthy == Some(false) { "unhealthy" } else { "healthy" }; FlowDaemonEntry { name: view.name, status: status.to_string(), running: view.running, healthy: view.healthy, pid: view.pid, health_target: view.health_target, description: view.description, } }) .collect(); entries.sort_by(|left, right| { right .running .cmp(&left.running) .then_with(|| left.status.cmp(&right.status)) .then_with(|| left.name.cmp(&right.name)) }); let running = entries.iter().filter(|entry| entry.running).count(); let unhealthy = entries .iter() .filter(|entry| entry.running && entry.healthy == Some(false)) .count(); let healthy = entries .iter() .filter(|entry| entry.running && entry.healthy != Some(false)) .count(); let stopped = entries.iter().filter(|entry| !entry.running).count(); Ok(FlowDaemonSnapshot { total: entries.len(), running, healthy, unhealthy, stopped, entries, }) } pub fn run_daemon_action( name: &str, action: FlowDaemonAction, config_path: Option<&Path>, ) -> Result<FlowDaemonSnapshot> { let action_name = match action { FlowDaemonAction::Start => "start", FlowDaemonAction::Stop => "stop", FlowDaemonAction::Restart => "restart", }; match action { FlowDaemonAction::Start => daemon::start_daemon_with_path(name, config_path)?, FlowDaemonAction::Stop => daemon::stop_daemon_with_path(name, config_path)?, FlowDaemonAction::Restart => { daemon::stop_daemon_with_path(name, config_path).ok(); daemon::start_daemon_with_path(name, config_path)?; } } let summary = match action_name { "start" => "started", "stop" => "stopped", "restart" => "restarted", _ => action_name, }; let mut activity_event = activity_log::ActivityEvent::done(format!("daemon.{action_name}"), summary); activity_event.scope = Some(name.to_string()); activity_event.source = Some("daemon-control".to_string()); let _ = activity_log::append_daily_event(activity_event); load_daemon_snapshot(config_path) } #[cfg(test)] mod tests { use super::*; #[test] fn classifies_and_counts_daemon_states() { let snapshot = FlowDaemonSnapshot { total: 3, running: 2, healthy: 1, unhealthy: 1, stopped: 1, entries: vec![ FlowDaemonEntry { name: "codexd".to_string(), status: "healthy".to_string(), running: true, healthy: Some(true), pid: Some(10), health_target: Some("unix:/tmp/codexd.sock".to_string()), description: None, }, FlowDaemonEntry { name: "api".to_string(), status: "unhealthy".to_string(), running: true, healthy: Some(false), pid: Some(11), health_target: Some("http://127.0.0.1:8780/health".to_string()), description: None, }, FlowDaemonEntry { name: "worker".to_string(), status: "stopped".to_string(), running: false, healthy: None, pid: None, health_target: None, description: None, }, ], }; assert_eq!(snapshot.total, 3); assert_eq!(snapshot.running, 2); assert_eq!(snapshot.healthy, 1); assert_eq!(snapshot.unhealthy, 1); assert_eq!(snapshot.stopped, 1); } } ================================================ FILE: src/db.rs ================================================ use std::path::PathBuf; use anyhow::{Context, Result}; use rusqlite::Connection; /// Path to the shared SQLite database. pub fn db_path() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".config/flow/flow.db") } /// Open the SQLite database, creating parent directories if needed. pub fn open_db() -> Result<Connection> { let path = db_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create db dir {}", parent.display()))?; } Connection::open(path).context("failed to open flow.db") } ================================================ FILE: src/deploy.rs ================================================ //! Deploy projects to hosts and cloud platforms. //! //! Supports: //! - Linux hosts via SSH (with systemd + nginx) //! - Cloudflare Workers //! - Railway use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use rpassword::prompt_password; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::cli::{DeployAction, DeployCommand, EnvAction, TaskRunOpts}; use crate::config::Config; use crate::deploy_setup::{ CloudflareSetupDefaults, CloudflareSetupResult, discover_wrangler_configs, run_cloudflare_setup, }; use crate::env::parse_env_file; use crate::release; use crate::services; use crate::tasks; const DEPLOY_HELPER_BIN: &str = "infra"; const DEPLOY_HELPER_REPO_DEFAULT: &str = "~/infra"; const DEPLOY_HELPER_ENV_BIN: &str = "FLOW_DEPLOY_HELPER_BIN"; const DEPLOY_HELPER_ENV_REPO: &str = "FLOW_DEPLOY_HELPER_REPO"; const DEPLOY_LOG_STATE_FILE: &str = ".flow/deploy-log.json"; #[derive(Debug, Deserialize)] struct InfraConfig { linux_host: Option<String>, linux_port: Option<String>, linux_user: Option<String>, } /// Host configuration stored globally at ~/.config/flow/deploy.json #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DeployConfig { /// SSH user@host:port for linux host deployments. pub host: Option<HostConnection>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HostConnection { pub user: String, pub host: String, pub port: u16, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct DeployLogState { last_deploy_unix: Option<i64>, } #[derive(Debug, Clone)] struct DeployProjectContext { project_root: PathBuf, config_path: PathBuf, flow_config: Option<Config>, } impl HostConnection { /// Parse connection string like "user@host:port" or "user@host". pub fn parse(s: &str) -> Result<Self> { let (user_host, port) = if let Some((uh, p)) = s.rsplit_once(':') { (uh, p.parse::<u16>().unwrap_or(22)) } else { (s, 22) }; let (user, host) = user_host .split_once('@') .context("connection string must be user@host[:port]")?; Ok(Self { user: user.to_string(), host: host.to_string(), port, }) } /// Format as user@host for SSH commands. pub fn ssh_target(&self) -> String { format!("{}@{}", self.user, self.host) } } /// Host deployment config from flow.toml [host] section. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HostConfig { /// Remote destination path (e.g., /opt/myapp). pub dest: Option<String>, /// Setup script to run after syncing. pub setup: Option<String>, /// Command to run the service. pub run: Option<String>, /// Port the service listens on. pub port: Option<u16>, /// Systemd service name. pub service: Option<String>, /// Path to .env file for secrets (used when env_source is not set). pub env_file: Option<String>, /// Env source for secrets ("cloud" or "file"). pub env_source: Option<String>, /// Specific env keys to fetch when env_source = "cloud". #[serde(default)] pub env_keys: Vec<String>, /// Fetch from project-scoped env vars instead of personal (default). #[serde(default)] pub env_project: bool, /// Environment name for cloud (defaults to "production"). pub environment: Option<String>, /// Service token for fetching env vars on host (set via f env token create). pub service_token: Option<String>, /// Public domain for nginx. pub domain: Option<String>, /// Enable SSL via Let's Encrypt. #[serde(default)] pub ssl: bool, } /// Cloudflare deployment config from flow.toml [cloudflare] section. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CloudflareConfig { /// Path to worker directory (relative to project root). pub path: Option<String>, /// Path to .env file for secrets. pub env_file: Option<String>, /// Env source for secrets ("cloud" or "file"). pub env_source: Option<String>, /// Specific env keys to fetch when env_source = "cloud". #[serde(default)] pub env_keys: Vec<String>, /// Env keys to set as non-secret vars when env_source = "cloud". #[serde(default)] pub env_vars: Vec<String>, /// Default values for env vars (key/value). #[serde(default)] pub env_defaults: HashMap<String, String>, /// Secret keys to bootstrap directly in Cloudflare. #[serde(default)] pub bootstrap_secrets: Vec<String>, /// Optional Jazz sync peer for bootstrap (env store). pub bootstrap_jazz_peer: Option<String>, /// Optional Jazz worker account name for bootstrap (env store). pub bootstrap_jazz_name: Option<String>, /// Optional Jazz sync peer for bootstrap (auth store). pub bootstrap_jazz_auth_peer: Option<String>, /// Optional Jazz worker account name for bootstrap (auth store). pub bootstrap_jazz_auth_name: Option<String>, /// Env apply mode: "always", "auto", or "never". pub env_apply: Option<String>, /// Wrangler environment name (e.g., staging). #[serde(default, alias = "env")] pub environment: Option<String>, /// Custom deploy command. pub deploy: Option<String>, /// Custom dev command. pub dev: Option<String>, /// URL for health checks (e.g., https://my-worker.workers.dev). pub url: Option<String>, } /// Production deploy overrides from flow.toml [prod] section. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProdConfig { /// Custom domain to serve (e.g., app.example.com). pub domain: Option<String>, /// Explicit route pattern (e.g., app.example.com/*). pub route: Option<String>, } /// Web deployment config from flow.toml [web] section. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WebConfig { /// Path to web app directory (relative to project root). pub path: Option<String>, /// Domain for the site (used to derive route). pub domain: Option<String>, /// Explicit route to add in wrangler config (e.g., example.com/*). pub route: Option<String>, /// Env source for secrets ("cloud" or "file"). pub env_source: Option<String>, /// Specific env keys to fetch when env_source = "cloud". #[serde(default)] pub env_keys: Vec<String>, /// Env keys to set as non-secret vars when env_source = "cloud". #[serde(default)] pub env_vars: Vec<String>, /// Default values for env vars (key/value). #[serde(default)] pub env_defaults: HashMap<String, String>, /// Env apply mode: "always", "auto", or "never". pub env_apply: Option<String>, /// Wrangler environment name (e.g., staging). #[serde(default, alias = "env")] pub environment: Option<String>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EnvApplyMode { Always, Auto, Never, } fn env_apply_mode_from_str(value: Option<&str>) -> EnvApplyMode { match value.map(|s| s.to_ascii_lowercase()) { Some(ref v) if v == "always" => EnvApplyMode::Always, Some(ref v) if v == "auto" => EnvApplyMode::Auto, Some(ref v) if v == "never" => EnvApplyMode::Never, _ => EnvApplyMode::Never, } } fn is_tls_connect_error(err: &anyhow::Error) -> bool { let msg = format!("{err:#}"); msg.contains("certificate was not trusted") || msg.contains("client error (Connect)") || msg.contains("failed to connect to cloud") } /// Railway deployment config from flow.toml [railway] section. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RailwayConfig { /// Railway project ID. pub project: Option<String>, /// Service name. pub service: Option<String>, /// Environment (production, staging). pub environment: Option<String>, /// Start command. pub start: Option<String>, /// Path to .env file. pub env_file: Option<String>, } /// Get the deploy config file path. fn deploy_config_path() -> PathBuf { dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("flow") .join("deploy.json") } /// Load global deploy config. pub fn load_deploy_config() -> Result<DeployConfig> { let path = deploy_config_path(); if path.exists() { let content = fs::read_to_string(&path)?; Ok(serde_json::from_str(&content).unwrap_or_default()) } else { Ok(DeployConfig::default()) } } /// Save global deploy config. pub fn save_deploy_config(config: &DeployConfig) -> Result<()> { let path = deploy_config_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = serde_json::to_string_pretty(config)?; fs::write(&path, content)?; Ok(()) } fn deploy_log_state_path(project_root: &Path) -> PathBuf { project_root.join(DEPLOY_LOG_STATE_FILE) } fn load_deploy_log_state(project_root: &Path) -> DeployLogState { let path = deploy_log_state_path(project_root); if let Ok(content) = fs::read_to_string(&path) { if let Ok(state) = serde_json::from_str::<DeployLogState>(&content) { return state; } } DeployLogState::default() } fn save_deploy_log_state(project_root: &Path, state: &DeployLogState) -> Result<()> { let path = deploy_log_state_path(project_root); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = serde_json::to_string_pretty(state)?; fs::write(path, content)?; Ok(()) } fn record_deploy_marker(project_root: &Path) -> Result<()> { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64; let mut state = load_deploy_log_state(project_root); state.last_deploy_unix = Some(now); save_deploy_log_state(project_root, &state) } /// Run the deploy command. pub fn run(cmd: DeployCommand) -> Result<()> { match cmd.action { Some(DeployAction::Config) => configure_deploy(), Some(DeployAction::Release(opts)) => release::run_task(opts), Some(DeployAction::Shell) => open_shell(), Some(DeployAction::SetHost { connection }) => set_host(&connection), Some(DeployAction::ShowHost) => show_host(), action => { let ctx = load_deploy_project_context()?; run_with_project_context(action, ctx) } } } fn run_with_project_context(action: Option<DeployAction>, ctx: DeployProjectContext) -> Result<()> { let DeployProjectContext { project_root, config_path, flow_config, } = ctx; match action { None => { // Auto-detect platform from flow.toml, or run deploy task if configured. if let Some(cfg) = flow_config.as_ref() { if let Some(task_name) = cfg.flow.deploy_task.as_deref() { if tasks::find_task(cfg, task_name).is_some() { return tasks::run(TaskRunOpts { config: config_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: task_name.to_string(), args: Vec::new(), }); } bail!( "deploy_task '{}' not found. Available tasks: {}", task_name, available_tasks(cfg) ); } if cfg.host.is_some() || cfg.cloudflare.is_some() || cfg.railway.is_some() { return auto_deploy(&project_root, Some(cfg)); } if tasks::find_task(cfg, "deploy").is_some() { return tasks::run(TaskRunOpts { config: config_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: "deploy".to_string(), args: Vec::new(), }); } bail!( "No deployment config found in flow.toml and no 'deploy' task is defined.\n\n\ Add one of:\n\ [host]\n\ dest = \"/opt/myapp\"\n\ run = \"./server\"\n\n\ [cloudflare]\n\ path = \"worker\"\n\n\ [railway]\n\ project = \"my-project\"\n\n\ Or run:\n\ f deploy setup" ); } bail!("No flow.toml found. Run `f setup` first.") } Some(DeployAction::Host { remote_build, setup, }) => deploy_host(&project_root, flow_config.as_ref(), remote_build, setup), Some(DeployAction::Cloudflare { secrets, dev }) => { deploy_cloudflare(&project_root, flow_config.as_ref(), secrets, dev) } Some(DeployAction::Web) => deploy_web(&project_root, flow_config.as_ref()), Some(DeployAction::Setup) => setup_cloudflare(&project_root, flow_config.as_ref()), Some(DeployAction::Railway) => deploy_railway(&project_root, flow_config.as_ref()), Some(DeployAction::Status) => show_status(&project_root, flow_config.as_ref()), Some(DeployAction::Logs { follow, since_deploy, all, lines, }) => show_logs( &project_root, flow_config.as_ref(), follow, since_deploy, all, lines, ), Some(DeployAction::Restart) => restart_service(&project_root, flow_config.as_ref()), Some(DeployAction::Stop) => stop_service(&project_root, flow_config.as_ref()), Some(DeployAction::Health { url, status }) => { check_health(&project_root, flow_config.as_ref(), url, status) } Some(DeployAction::Config) | Some(DeployAction::Release(_)) | Some(DeployAction::Shell) | Some(DeployAction::SetHost { .. }) | Some(DeployAction::ShowHost) => unreachable!("handled before project context load"), } } /// Run a production deploy (skips flow.deploy_task and prefers deploy-prod/prod tasks). pub fn run_prod(cmd: DeployCommand) -> Result<()> { match cmd.action { Some(DeployAction::Config) => configure_deploy(), Some(DeployAction::Release(opts)) => release::run_task(opts), Some(DeployAction::Shell) => open_shell(), Some(DeployAction::SetHost { connection }) => set_host(&connection), Some(DeployAction::ShowHost) => show_host(), action => { let ctx = load_deploy_project_context()?; run_prod_with_project_context(action, ctx) } } } fn run_prod_with_project_context( action: Option<DeployAction>, ctx: DeployProjectContext, ) -> Result<()> { let DeployProjectContext { project_root, config_path, flow_config, } = ctx; match action { None => { let cfg = flow_config .as_ref() .context("No flow.toml found. Run `f init` first.")?; if tasks::find_task(cfg, "deploy-prod").is_some() { return tasks::run(TaskRunOpts { config: config_path.clone(), delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: "deploy-prod".to_string(), args: Vec::new(), }); } if tasks::find_task(cfg, "prod").is_some() { return tasks::run(TaskRunOpts { config: config_path.clone(), delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: "prod".to_string(), args: Vec::new(), }); } if cfg.host.is_some() || cfg.cloudflare.is_some() || cfg.railway.is_some() || cfg.web.is_some() { if cfg.host.is_some() { println!("Detected [host] config, deploying to Linux host..."); return deploy_host(&project_root, Some(cfg), false, false); } if cfg.cloudflare.is_some() { println!("Detected [cloudflare] config, deploying to Cloudflare..."); if let Err(err) = ensure_prod_cloudflare_routes(&project_root, cfg) { eprintln!("WARN prod route setup skipped: {err}"); } return deploy_cloudflare(&project_root, Some(cfg), false, false); } if cfg.railway.is_some() { println!("Detected [railway] config, deploying to Railway..."); return deploy_railway(&project_root, Some(cfg)); } if cfg.web.is_some() { println!("Detected [web] config, deploying web..."); return deploy_web(&project_root, Some(cfg)); } } bail!( "No production deploy config found in flow.toml.\n\n\ Add one of:\n\ [host]\n\ dest = \"/opt/myapp\"\n\ run = \"./server\"\n\n\ [cloudflare]\n\ path = \"worker\"\n\n\ [railway]\n\ project = \"my-project\"\n\n\ [web]\n\ path = \"packages/web\"\n\n\ Or define a deploy-prod/prod task." ); } Some(DeployAction::Host { remote_build, setup, }) => deploy_host(&project_root, flow_config.as_ref(), remote_build, setup), Some(DeployAction::Cloudflare { secrets, dev }) => { if let Some(cfg) = flow_config.as_ref() { if let Err(err) = ensure_prod_cloudflare_routes(&project_root, cfg) { eprintln!("WARN prod route setup skipped: {err}"); } } deploy_cloudflare(&project_root, flow_config.as_ref(), secrets, dev) } Some(DeployAction::Web) => deploy_web(&project_root, flow_config.as_ref()), Some(DeployAction::Setup) => setup_cloudflare(&project_root, flow_config.as_ref()), Some(DeployAction::Railway) => deploy_railway(&project_root, flow_config.as_ref()), Some(DeployAction::Status) => show_status(&project_root, flow_config.as_ref()), Some(DeployAction::Logs { follow, since_deploy, all, lines, }) => show_logs( &project_root, flow_config.as_ref(), follow, since_deploy, all, lines, ), Some(DeployAction::Restart) => restart_service(&project_root, flow_config.as_ref()), Some(DeployAction::Stop) => stop_service(&project_root, flow_config.as_ref()), Some(DeployAction::Health { url, status }) => { check_health(&project_root, flow_config.as_ref(), url, status) } Some(DeployAction::Config) | Some(DeployAction::Release(_)) | Some(DeployAction::Shell) | Some(DeployAction::SetHost { .. }) | Some(DeployAction::ShowHost) => unreachable!("handled before project context load"), } } fn configure_deploy() -> Result<()> { println!("Deploy config (Linux host via SSH)."); let existing = load_deploy_config()?.host; let infra_default = infra_linux_connection_string(); if let Some(conn) = existing.as_ref() { println!("Current host: {}@{}:{}", conn.user, conn.host, conn.port); } if let Some(default_conn) = infra_default.as_ref() { if existing.is_none() { println!("Detected infra host: {default_conn}"); } } let default_conn = existing .as_ref() .map(|conn| format!("{}@{}:{}", conn.user, conn.host, conn.port)) .or(infra_default); let prompt = "SSH host (user@host:port)"; let input = prompt_line(prompt, default_conn.as_deref())?; let trimmed = input.trim(); if trimmed.is_empty() { println!("No changes."); return Ok(()); } let conn = HostConnection::parse(trimmed)?; let mut cfg = load_deploy_config()?; cfg.host = Some(conn.clone()); save_deploy_config(&cfg)?; println!("✓ Host set: {}@{}:{}", conn.user, conn.host, conn.port); println!("Next: run `f setup release` to scaffold host config, then `f deploy`."); Ok(()) } fn load_deploy_project_context() -> Result<DeployProjectContext> { let cwd = std::env::current_dir()?; let config_path = crate::project_snapshot::find_flow_toml_upwards(&cwd) .unwrap_or_else(|| cwd.join("flow.toml")); let project_root = config_path.parent().unwrap_or(&cwd).to_path_buf(); let flow_config = if config_path.exists() { Some(crate::config::load(&config_path)?) } else { None }; Ok(DeployProjectContext { project_root, config_path, flow_config, }) } pub fn ensure_deploy_helper() -> Result<Option<PathBuf>> { if let Ok(bin_override) = std::env::var(DEPLOY_HELPER_ENV_BIN) { let path = crate::config::expand_path(&bin_override); if path.exists() { return Ok(Some(path)); } } if let Ok(path) = which::which(DEPLOY_HELPER_BIN) { return Ok(Some(path)); } let repo = deploy_helper_repo(); if !repo.exists() { println!( "Deploy helper not found. Set {} or install it to continue.", DEPLOY_HELPER_ENV_BIN ); return Ok(None); } println!("Installing deploy helper..."); let status = Command::new("cargo") .args(["build", "--release"]) .current_dir(&repo) .status() .context("failed to build deploy helper")?; if !status.success() { bail!("deploy helper build failed"); } let bin_path = repo.join("target/release").join(DEPLOY_HELPER_BIN); let install_dir = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".local/bin"); fs::create_dir_all(&install_dir) .with_context(|| format!("failed to create {}", install_dir.display()))?; let install_path = install_dir.join(DEPLOY_HELPER_BIN); fs::copy(&bin_path, &install_path) .with_context(|| format!("failed to copy {}", install_path.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&install_path)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&install_path, perms)?; } println!("Deploy helper installed."); Ok(Some(install_path)) } fn deploy_helper_repo() -> PathBuf { if let Ok(repo_override) = std::env::var(DEPLOY_HELPER_ENV_REPO) { return crate::config::expand_path(&repo_override); } crate::config::expand_path(DEPLOY_HELPER_REPO_DEFAULT) } fn infra_linux_connection_string() -> Option<String> { let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); let paths = [ base.join("infra").join("config.json"), crate::config::global_config_dir().join("infra/config.json"), ]; for path in paths { if !path.exists() { continue; } let content = fs::read_to_string(&path).ok()?; let cfg: InfraConfig = serde_json::from_str(&content).ok()?; let user = cfg.linux_user?; let host = cfg.linux_host?; let port = cfg.linux_port.unwrap_or_else(|| "22".to_string()); return Some(format!("{}@{}:{}", user, host, port)); } None } pub fn default_linux_connection_string() -> Option<String> { infra_linux_connection_string() } fn prompt_line(message: &str, default: Option<&str>) -> Result<String> { if let Some(default) = default { print!("{message} [{default}]: "); } else { print!("{message}: "); } std::io::stdout().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(default.unwrap_or("").to_string()); } Ok(trimmed.to_string()) } fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> { let prompt = if default_yes { "[Y/n]" } else { "[y/N]" }; print!("{message} {prompt}: "); std::io::stdout().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_yes); } Ok(answer == "y" || answer == "yes") } fn prompt_secret(message: &str) -> Result<String> { let value = prompt_password(message)?; Ok(value) } fn available_tasks(cfg: &crate::config::Config) -> String { let mut names: Vec<_> = cfg.tasks.iter().map(|task| task.name.clone()).collect(); names.sort(); names.join(", ") } /// Auto-detect platform and deploy. fn auto_deploy(project_root: &Path, config: Option<&Config>) -> Result<()> { let config = config.context("No flow.toml found. Run 'f init' first.")?; // Check which platform configs exist if config.host.is_some() { println!("Detected [host] config, deploying to Linux host..."); return deploy_host(project_root, Some(config), false, false); } if config.cloudflare.is_some() { println!("Detected [cloudflare] config, deploying to Cloudflare..."); return deploy_cloudflare(project_root, Some(config), false, false); } if config.railway.is_some() { println!("Detected [railway] config, deploying to Railway..."); return deploy_railway(project_root, Some(config)); } bail!( "No deployment config found in flow.toml.\n\n\ Add one of:\n\ [host]\n\ dest = \"/opt/myapp\"\n\ run = \"./server\"\n\n\ [cloudflare]\n\ path = \"worker\"\n\n\ [railway]\n\ project = \"my-project\"\n\n\ Or run:\n\ f deploy setup" ); } fn deploy_web(project_root: &Path, config: Option<&Config>) -> Result<()> { let (web_root, flow_path, mut cfg) = resolve_deploy_root(project_root, config)?; let mut changed = false; if ensure_web_config(&flow_path, &web_root, &cfg)? { changed = true; cfg = crate::config::load(&flow_path)?; } let web_cfg = cfg.web.as_ref().context("No [web] section in flow.toml")?; if ensure_web_domain_or_route(&flow_path, web_cfg)? { changed = true; cfg = crate::config::load(&flow_path)?; } let web_cfg = cfg.web.as_ref().context("No [web] section in flow.toml")?; if ensure_web_env_source(&flow_path, web_cfg)? { changed = true; cfg = crate::config::load(&flow_path)?; } let web_cfg = cfg.web.as_ref().context("No [web] section in flow.toml")?; if ensure_web_routes(&web_root, web_cfg)? { changed = true; } if changed { println!("Updated web deployment config."); } ensure_cloudflare_api_token()?; ensure_web_dns(web_cfg)?; if let Err(err) = apply_web_env(&web_root, web_cfg) { eprintln!("WARN env apply skipped: {err}"); eprintln!("Hint: run `f env setup` to store missing web env vars."); } if tasks::find_task(&cfg, "deploy-web").is_some() { return tasks::run(TaskRunOpts { config: flow_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: "deploy-web".to_string(), args: Vec::new(), }); } if tasks::find_task(&cfg, "deploy").is_some() { eprintln!("WARN deploy-web task not found; running deploy."); return tasks::run(TaskRunOpts { config: flow_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: "deploy".to_string(), args: Vec::new(), }); } bail!("No deploy task found. Add 'deploy-web' or 'deploy' to flow.toml."); } fn resolve_deploy_root( project_root: &Path, config: Option<&Config>, ) -> Result<(PathBuf, PathBuf, Config)> { let Some(flow_path) = find_flow_toml_from(project_root) else { bail!("flow.toml not found. Run from your repo root."); }; let root = flow_path.parent().unwrap_or(project_root).to_path_buf(); let cfg = if root == project_root { match config { Some(existing) => existing.clone(), None => crate::config::load(&flow_path)?, } } else { crate::config::load(&flow_path)? }; Ok((root, flow_path, cfg)) } fn find_flow_toml_from(start: &Path) -> Option<PathBuf> { let mut current = start.to_path_buf(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } /// Deploy to a Linux host via SSH. fn deploy_host( project_root: &Path, config: Option<&Config>, _remote_build: bool, force_setup: bool, ) -> Result<()> { let deploy_config = load_deploy_config()?; let conn = deploy_config .host .as_ref() .context("No host configured. Run: f deploy set-host user@host:port")?; let host_cfg = config .and_then(|c| c.host.as_ref()) .context("No [host] section in flow.toml")?; let dest = host_cfg.dest.as_deref().unwrap_or("/opt/app"); let service_name = host_cfg .service .as_deref() .unwrap_or_else(|| project_root.file_name().unwrap().to_str().unwrap()); println!("Deploying to {}:{}", conn.ssh_target(), dest); // 1. Sync files via rsync println!("\n==> Syncing files..."); rsync_upload(project_root, conn, dest)?; // 2. Handle env vars let use_cloud = is_cloud_source(host_cfg.env_source.as_deref()); let use_flow = is_flow_source(host_cfg.env_source.as_deref()); let has_service_token = host_cfg.service_token.is_some(); let use_cloud_token_mode = use_cloud || host_cfg .env_source .as_deref() .map(|s| s.eq_ignore_ascii_case("flow")) .unwrap_or(false); if use_cloud_token_mode && has_service_token { // Service token mode: install fetch script, host fetches env vars on startup let service_token = host_cfg.service_token.as_ref().unwrap(); let env_name = host_cfg.environment.as_deref().unwrap_or("production"); let project_name = project_root.file_name().unwrap().to_str().unwrap(); let api_base = crate::env::load_env_api_url().unwrap_or_else(|_| "https://myflow.sh".to_string()); println!("==> Installing env-fetch script (host will fetch on startup)..."); install_env_fetch_script( conn, dest, service_token, &api_base, project_name, env_name, &host_cfg.env_keys, )?; } else if use_cloud || use_flow { // Deploy-time fetch mode: fetch now and copy to host let env_name = host_cfg.environment.as_deref().unwrap_or("production"); let keys = &host_cfg.env_keys; let use_project = host_cfg.env_project; if !keys.is_empty() { let source = if use_project { format!("project/{}", env_name) } else { "personal".to_string() }; let source_label = if use_cloud { "cloud" } else { "flow" }; println!( "==> Fetching env vars from {} ({})...", source_label, source ); let fetch = || { if use_project { crate::env::fetch_project_env_vars(env_name, keys) } else { crate::env::fetch_personal_env_vars(keys) } }; let result = if use_flow && host_cfg.env_source.as_deref() == Some("local") { with_local_env_backend(fetch) } else { fetch() }; match result { Ok(mut vars) if !vars.is_empty() => { if !keys.is_empty() { let key_set: HashSet<_> = keys.iter().collect(); vars.retain(|k, _| key_set.contains(k)); } // Generate .env content let mut content = String::new(); content.push_str(&format!( "# Source: {} {} (fetched at deploy)\n", source_label, source )); let mut sorted_keys: Vec<_> = vars.keys().collect(); sorted_keys.sort(); for key in sorted_keys { let value = &vars[key]; let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{}=\"{}\"\n", key, escaped)); } // Write to temp file and scp let temp_env = std::env::temp_dir().join(format!(".env.{}", std::process::id())); fs::write(&temp_env, &content)?; let remote_env = format!("{}/.env", dest); println!("==> Copying {} env vars to remote...", vars.len()); scp_file(&temp_env, conn, &remote_env)?; let _ = fs::remove_file(&temp_env); } Ok(_) => { eprintln!("⚠ No env vars found in {} for {}", source_label, source); } Err(err) => { eprintln!("⚠ Failed to fetch env vars from {}: {}", source_label, err); } } } } else if let Some(env_file) = &host_cfg.env_file { let local_env = project_root.join(env_file); if local_env.exists() { println!("==> Copying {}...", env_file); let remote_env = format!("{}/.env", dest); scp_file(&local_env, conn, &remote_env)?; } } // 3. Run setup script if needed if let Some(setup) = &host_cfg.setup { if force_setup || !service_exists(conn, service_name)? { println!("==> Running setup..."); ssh_run(conn, &format!("cd {} && {}", dest, setup))?; } } // 4. Create/update systemd service if let Some(run_cmd) = &host_cfg.run { println!("==> Configuring systemd service: {}", service_name); create_systemd_service(conn, service_name, dest, run_cmd, host_cfg)?; } // 5. Configure nginx if domain specified if let Some(domain) = &host_cfg.domain { if let Some(port) = host_cfg.port { println!("==> Configuring nginx for {}", domain); setup_nginx(conn, domain, port, host_cfg.ssl)?; } } // 6. Restart service println!("==> Starting service..."); ssh_run(conn, &format!("systemctl restart {}", service_name))?; println!("\n✓ Deployed successfully!"); if let Some(domain) = &host_cfg.domain { let scheme = if host_cfg.ssl { "https" } else { "http" }; println!(" URL: {}://{}", scheme, domain); } if let Err(err) = record_deploy_marker(project_root) { eprintln!("⚠ Failed to record deploy timestamp: {err}"); } Ok(()) } /// Deploy to Cloudflare Workers. fn deploy_cloudflare( project_root: &Path, config: Option<&Config>, set_secrets: bool, dev_mode: bool, ) -> Result<()> { let default_cf = CloudflareConfig::default(); let cf_cfg = config .and_then(|c| c.cloudflare.as_ref()) .unwrap_or(&default_cf); let worker_path = cf_cfg .path .as_ref() .map(|p| project_root.join(p)) .unwrap_or_else(|| project_root.to_path_buf()); ensure_wrangler_config(&worker_path)?; let env_name = cf_cfg.environment.as_deref(); let env_apply_mode = if set_secrets { EnvApplyMode::Always } else { env_apply_mode_from_str(cf_cfg.env_apply.as_deref()) }; let should_apply = matches!(env_apply_mode, EnvApplyMode::Always | EnvApplyMode::Auto); let source = cf_cfg.env_source.as_deref(); let use_cloud = is_cloud_source(source); let use_flow = is_flow_source(source); let use_env_store = use_cloud || use_flow; let source_label = if use_cloud { "cloud" } else { "flow" }; let cloud_env = env_name.unwrap_or("production"); let mut cloud_vars: HashMap<String, String> = HashMap::new(); let mut cloud_loaded = false; if use_env_store { let keys = collect_cloudflare_env_keys(cf_cfg); if !cf_cfg.env_defaults.is_empty() { for key in &keys { if let Some(value) = cf_cfg.env_defaults.get(key) { if !value.trim().is_empty() { cloud_vars.insert(key.clone(), value.clone()); } } } } if !keys.is_empty() { let fetch = || crate::env::fetch_project_env_vars(cloud_env, &keys); let result = if use_flow && source == Some("local") { with_local_env_backend(fetch) } else { fetch() }; match result { Ok(vars) => { if !vars.is_empty() { cloud_loaded = true; } cloud_vars.extend(vars); } Err(err) => { if env_apply_mode == EnvApplyMode::Auto { if is_tls_connect_error(&err) { eprintln!( "⚠ Unable to reach cloud (TLS/connect). Skipping env sync for now." ); } else { eprintln!("⚠ Env sync skipped: {err}"); } } else if env_apply_mode == EnvApplyMode::Always { eprintln!("⚠ Env sync skipped: {err}"); } else { eprintln!("⚠ Env sync skipped: {err}"); } } } } } if should_apply { if use_env_store { if cloud_loaded { apply_cloudflare_env_map(project_root, cf_cfg, &cloud_vars)?; } else if env_apply_mode == EnvApplyMode::Always { eprintln!( "⚠ No env vars found in {} for environment '{}' (using defaults only).", source_label, cloud_env ); } } else if let Some(env_file) = &cf_cfg.env_file { let env_path = project_root.join(env_file); if env_path.exists() { println!("==> Setting secrets from {}...", env_file); set_wrangler_secrets(&worker_path, &env_path, env_name, None)?; } } } // Deploy or dev let cmd = if dev_mode { cf_cfg.dev.as_deref().unwrap_or("wrangler dev") } else { cf_cfg.deploy.as_deref().unwrap_or("wrangler deploy") }; let cmd = append_env_arg(cmd, env_name); println!("==> Running: {}", cmd); let mut deploy_cmd = Command::new("sh"); deploy_cmd .arg("-c") .arg(cmd) .current_dir(&worker_path) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); if use_env_store && !cloud_vars.is_empty() { deploy_cmd.envs(&cloud_vars); } let status = deploy_cmd.status()?; if !status.success() { bail!("Cloudflare deployment failed"); } println!("\n✓ Deployed to Cloudflare!"); Ok(()) } pub fn apply_cloudflare_env(project_root: &Path, config: Option<&Config>) -> Result<()> { let cf_cfg = config .and_then(|c| c.cloudflare.as_ref()) .context("No [cloudflare] section in flow.toml")?; apply_cloudflare_env_from_config(project_root, cf_cfg) } pub fn set_cloudflare_secrets( project_root: &Path, config: Option<&Config>, secrets: &HashMap<String, String>, ) -> Result<()> { let cf_cfg = config .and_then(|c| c.cloudflare.as_ref()) .context("No [cloudflare] section in flow.toml")?; let worker_path = cf_cfg .path .as_ref() .map(|p| project_root.join(p)) .unwrap_or_else(|| project_root.to_path_buf()); ensure_wrangler_config(&worker_path)?; let env_name = cf_cfg.environment.as_deref(); let mut keys: Vec<_> = secrets.keys().cloned().collect(); keys.sort(); for key in keys { if let Some(value) = secrets.get(&key) { println!(" Setting secret {}...", key); set_wrangler_secret_value(&worker_path, env_name, &key, value)?; } } Ok(()) } fn apply_cloudflare_env_from_config(project_root: &Path, cf_cfg: &CloudflareConfig) -> Result<()> { let source = cf_cfg.env_source.as_deref(); if !is_cloud_source(source) && !is_flow_source(source) { bail!( "cloudflare.env_source must be set to \"cloud\", \"flow\", or \"local\" to apply envs" ); } let cloud_env = cf_cfg.environment.as_deref().unwrap_or("production"); let keys = collect_cloudflare_env_keys(cf_cfg); let fetch = || crate::env::fetch_project_env_vars(cloud_env, &keys); let vars = if is_flow_source(source) && source == Some("local") { with_local_env_backend(fetch)? } else { fetch()? }; if vars.is_empty() { bail!( "No env vars found in env store for environment '{}'", cloud_env ); } apply_cloudflare_env_map(project_root, cf_cfg, &vars)?; Ok(()) } fn collect_cloudflare_env_keys(cf_cfg: &CloudflareConfig) -> Vec<String> { let mut keys = Vec::new(); let mut seen = HashSet::new(); for key in cf_cfg.env_keys.iter().chain(cf_cfg.env_vars.iter()) { if seen.insert(key.clone()) { keys.push(key.clone()); } } keys } fn apply_cloudflare_env_map( project_root: &Path, cf_cfg: &CloudflareConfig, vars: &HashMap<String, String>, ) -> Result<()> { let worker_path = cf_cfg .path .as_ref() .map(|p| project_root.join(p)) .unwrap_or_else(|| project_root.to_path_buf()); ensure_wrangler_config(&worker_path)?; let wrangler_env = cf_cfg.environment.as_deref(); let var_keys: HashSet<String> = cf_cfg.env_vars.iter().cloned().collect(); println!("==> Applying {} env var(s) from env store...", vars.len()); set_wrangler_env_map(&worker_path, wrangler_env, vars, &var_keys)?; Ok(()) } fn ensure_wrangler_config(worker_path: &Path) -> Result<()> { let has_wrangler = worker_path.join("wrangler.toml").exists() || worker_path.join("wrangler.jsonc").exists() || worker_path.join("wrangler.json").exists(); if !has_wrangler { bail!( "No wrangler config found in {}.\n\ Create a wrangler.toml or run: npx wrangler init", worker_path.display() ); } Ok(()) } fn wrangler_command(worker_path: &Path) -> Command { let local_bin = worker_path .join("node_modules") .join(".bin") .join("wrangler"); let mut cmd = if local_bin.exists() { Command::new(local_bin) } else if worker_path.join("package.json").exists() { let mut cmd = Command::new("pnpm"); cmd.args(["exec", "wrangler"]); cmd } else { Command::new("wrangler") }; cmd.current_dir(worker_path); cmd } fn is_cloud_source(source: Option<&str>) -> bool { matches!( source.map(|s| s.to_ascii_lowercase()).as_deref(), Some("cloud") | Some("remote") | Some("myflow") ) } fn is_flow_source(source: Option<&str>) -> bool { matches!( source.map(|s| s.to_ascii_lowercase()).as_deref(), Some("flow") | Some("local") ) } fn maybe_bootstrap_secrets( worker_path: &Path, cf_cfg: &CloudflareConfig, env_name: &str, ) -> Result<()> { if cf_cfg.bootstrap_secrets.is_empty() { return Ok(()); } let mut env_store_missing = false; let existing = match crate::env::fetch_project_env_vars(env_name, &cf_cfg.bootstrap_secrets) { Ok(vars) => vars, Err(err) => { let msg = format!("{err:#}"); if msg.contains("Project not found.") || msg.contains("Personal env vars not found.") { env_store_missing = true; HashMap::new() } else { eprintln!("⚠ Unable to check bootstrap secrets: {err}"); println!("Run `f env bootstrap` later if needed."); return Ok(()); } } }; let missing: Vec<String> = cf_cfg .bootstrap_secrets .iter() .filter(|key| { existing .get(*key) .map(|value| value.trim().is_empty()) .unwrap_or(true) }) .cloned() .collect(); if missing.is_empty() { println!("Bootstrap secrets already configured; skipping."); return Ok(()); } if let Ok(present) = list_cloudflare_secret_keys(worker_path, cf_cfg.environment.as_deref()) { if missing.iter().all(|key| present.contains(key)) { println!( "Bootstrap secrets missing in cloud but already present in Cloudflare; skipping." ); println!("Run `f env bootstrap` if you want to rotate/store them in cloud."); return Ok(()); } } if env_store_missing { println!("cloud env space not found yet; bootstrap will initialize it."); } println!("Bootstrap secrets missing: {}", missing.join(", ")); println!("==> Bootstrapping secrets (optional)..."); crate::env::run(Some(EnvAction::Bootstrap))?; Ok(()) } fn list_cloudflare_secret_keys( worker_path: &Path, env_name: Option<&str>, ) -> Result<HashSet<String>> { let mut cmd = wrangler_command(worker_path); cmd.args(["secret", "list", "--json"]); if let Some(env) = env_name { cmd.args(["--env", env]); } let output = cmd.output()?; if !output.status.success() { bail!("wrangler secret list failed"); } let value: serde_json::Value = serde_json::from_slice(&output.stdout) .context("failed to parse wrangler secret list output")?; let mut keys = HashSet::new(); if let Some(items) = value.as_array() { for item in items { if let Some(name) = item.get("name").and_then(|val| val.as_str()) { keys.insert(name.to_string()); } } } Ok(keys) } fn set_wrangler_env_map( worker_path: &Path, env_name: Option<&str>, vars: &HashMap<String, String>, var_keys: &HashSet<String>, ) -> Result<()> { for (key, value) in vars { if var_keys.contains(key) { println!(" Setting var {}...", key); set_wrangler_var_value(worker_path, env_name, key, value)?; } else { println!(" Setting secret {}...", key); set_wrangler_secret_value(worker_path, env_name, key, value)?; } } Ok(()) } fn set_wrangler_var_value( worker_path: &Path, env_name: Option<&str>, key: &str, value: &str, ) -> Result<()> { let mut cmd = wrangler_command(worker_path); cmd.args(["vars", "set", key, value]); if let Some(env) = env_name { cmd.args(["--env", env]); } let status = cmd .stdin(Stdio::null()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status()?; if !status.success() { bail!("Failed to set wrangler var {}", key); } Ok(()) } fn set_wrangler_secret_value( worker_path: &Path, env_name: Option<&str>, key: &str, value: &str, ) -> Result<()> { let mut cmd = wrangler_command(worker_path); cmd.args(["secret", "put", key]); if let Some(env) = env_name { cmd.args(["--env", env]); } let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn()?; if let Some(mut stdin) = child.stdin.take() { writeln!(stdin, "{}", value)?; } let status = child.wait()?; if !status.success() { bail!("Failed to set wrangler secret {}", key); } Ok(()) } fn setup_cloudflare(project_root: &Path, config: Option<&Config>) -> Result<()> { let default_cf = CloudflareConfig::default(); let cf_cfg = config .and_then(|c| c.cloudflare.as_ref()) .unwrap_or(&default_cf); if is_cloud_source(cf_cfg.env_source.as_deref()) { let worker_path = if let Some(path) = cf_cfg.path.as_ref() { project_root.join(path) } else { let workers = discover_wrangler_configs(project_root)?; if workers.is_empty() { println!("No Cloudflare Worker config found (wrangler.toml/json)."); println!("Run `wrangler init` first, then try: f deploy setup"); return Ok(()); } if workers.len() > 1 { bail!( "Multiple Cloudflare worker configs found. Set [cloudflare].path in flow.toml." ); } workers[0].clone() }; ensure_wrangler_config(&worker_path)?; println!("Using Cloudflare worker: {}", worker_path.display()); let env_name = cf_cfg .environment .clone() .unwrap_or_else(|| "production".to_string()); maybe_bootstrap_secrets(&worker_path, cf_cfg, &env_name)?; let keys = collect_cloudflare_env_keys(cf_cfg); let env_store_ok = if keys.is_empty() { true } else { match crate::env::fetch_project_env_vars(&env_name, &keys) { Ok(_) => true, Err(err) => { let msg = format!("{err:#}"); if msg.contains("Project not found.") { println!("Project not found yet; it will be created on first set."); true } else { eprintln!("⚠ Env store unavailable: {err}"); false } } } }; if env_store_ok { if let Some(flow_cfg) = config { services::maybe_run_stripe_setup(project_root, flow_cfg, &env_name)?; } crate::env::run(Some(EnvAction::Guide { environment: env_name, }))?; crate::env::run(Some(EnvAction::Apply))?; } else { eprintln!("⚠ Skipping env guide/apply (cloud unavailable)."); } println!("\n✓ Cloudflare deploy setup complete."); return Ok(()); } let defaults = CloudflareSetupDefaults { worker_path: cf_cfg.path.as_ref().map(|p| project_root.join(p)), env_file: if is_cloud_source(cf_cfg.env_source.as_deref()) { None } else { cf_cfg.env_file.as_ref().map(|p| project_root.join(p)) }, environment: cf_cfg.environment.clone(), }; let result = run_cloudflare_setup(project_root, defaults)?; let Some(result) = result else { return Ok(()); }; let flow_path = project_root.join("flow.toml"); if !flow_path.exists() { bail!("flow.toml not found. Run `f init` first."); } update_flow_toml_cloudflare(&flow_path, project_root, &result)?; if result.apply_secrets { if is_cloud_source(cf_cfg.env_source.as_deref()) { let env_name = result .environment .clone() .unwrap_or_else(|| "production".to_string()); maybe_bootstrap_secrets(&result.worker_path, cf_cfg, &env_name)?; crate::env::run(Some(EnvAction::Guide { environment: env_name, }))?; crate::env::run(Some(EnvAction::Apply))?; } else if let Some(env_file) = result.env_file.as_ref() { let env_name = result.environment.as_deref(); set_wrangler_secrets( &result.worker_path, env_file, env_name, Some(&result.selected_keys), )?; } } println!("\n✓ Cloudflare deploy setup complete."); Ok(()) } /// Deploy to Railway. fn deploy_railway(project_root: &Path, config: Option<&Config>) -> Result<()> { let default_rail = RailwayConfig::default(); let rail_cfg = config .and_then(|c| c.railway.as_ref()) .unwrap_or(&default_rail); // Check railway CLI if which::which("railway").is_err() { bail!("Railway CLI not found. Install: npm install -g @railway/cli"); } // Link project if specified if let (Some(project), Some(env)) = (&rail_cfg.project, &rail_cfg.environment) { println!("==> Linking to Railway project..."); let status = Command::new("railway") .args(["link", project, "--environment", env]) .current_dir(project_root) .status()?; if !status.success() { bail!("Failed to link Railway project"); } } // Set env vars from file if let Some(env_file) = &rail_cfg.env_file { let env_path = project_root.join(env_file); if env_path.exists() { println!("==> Setting environment variables..."); set_railway_env(&env_path)?; } } // Deploy println!("==> Deploying to Railway..."); let status = Command::new("railway") .args(["up", "--detach"]) .current_dir(project_root) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status()?; if !status.success() { bail!("Railway deployment failed"); } println!("\n✓ Deployed to Railway!"); Ok(()) } /// Show deployment status. fn show_status(_project_root: &Path, config: Option<&Config>) -> Result<()> { let deploy_config = load_deploy_config()?; println!("Deployment Status\n"); // Host status if let Some(conn) = &deploy_config.host { println!("Host: {}@{}:{}", conn.user, conn.host, conn.port); if let Some(cfg) = config.and_then(|c| c.host.as_ref()) { if let Some(service) = &cfg.service { let output = ssh_capture( conn, &format!( "systemctl is-active {} 2>/dev/null || echo inactive", service ), )?; println!(" Service '{}': {}", service, output.trim()); } } } else { println!("Host: not configured"); } Ok(()) } /// Show deployment logs. fn show_logs( project_root: &Path, config: Option<&Config>, follow: bool, since_deploy: bool, all: bool, lines: usize, ) -> Result<()> { if let Some(cf_cfg) = config.and_then(|c| c.cloudflare.as_ref()) { return show_cloudflare_logs(project_root, cf_cfg, follow, lines); } let deploy_config = load_deploy_config()?; let conn = deploy_config.host.as_ref().context("No host configured")?; let service = config .and_then(|c| c.host.as_ref()) .and_then(|h| h.service.as_ref()) .context("No service name in [host] config")?; let use_since_deploy = since_deploy && !all; let since_flag = if use_since_deploy { let state = load_deploy_log_state(project_root); if let Some(ts) = state.last_deploy_unix { format!("--since '@{}'", ts) } else { String::new() } } else { String::new() }; let follow_flag = if follow { "-f" } else { "" }; let cmd = format!( "journalctl -u {} -n {} {} {} --no-pager", service, lines, follow_flag, since_flag ); ssh_run(conn, &cmd)?; Ok(()) } fn show_cloudflare_logs( project_root: &Path, cf_cfg: &CloudflareConfig, follow: bool, lines: usize, ) -> Result<()> { let worker_path = cf_cfg .path .as_ref() .map(|p| project_root.join(p)) .unwrap_or_else(|| project_root.to_path_buf()); ensure_wrangler_config(&worker_path)?; if !follow { eprintln!("Note: wrangler tail streams logs until you stop it (Ctrl+C)."); let _ = lines; } let mut cmd = wrangler_command(&worker_path); cmd.arg("tail").args(["--format", "pretty"]); if let Some(env) = cf_cfg.environment.as_deref() { cmd.args(["--env", env]); } let status = cmd .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status()?; if !status.success() { bail!("Cloudflare log tail failed"); } Ok(()) } /// Restart the deployed service. fn restart_service(_project_root: &Path, config: Option<&Config>) -> Result<()> { let deploy_config = load_deploy_config()?; let conn = deploy_config.host.as_ref().context("No host configured")?; let service = config .and_then(|c| c.host.as_ref()) .and_then(|h| h.service.as_ref()) .context("No service name")?; println!("Restarting {}...", service); ssh_run(conn, &format!("systemctl restart {}", service))?; println!("✓ Restarted"); Ok(()) } /// Stop the deployed service. fn stop_service(_project_root: &Path, config: Option<&Config>) -> Result<()> { let deploy_config = load_deploy_config()?; let conn = deploy_config.host.as_ref().context("No host configured")?; let service = config .and_then(|c| c.host.as_ref()) .and_then(|h| h.service.as_ref()) .context("No service name")?; println!("Stopping {}...", service); ssh_run(conn, &format!("systemctl stop {}", service))?; println!("✓ Stopped"); Ok(()) } /// Open SSH shell to host. fn open_shell() -> Result<()> { let deploy_config = load_deploy_config()?; let conn = deploy_config.host.as_ref().context("No host configured")?; println!("Connecting to {}...", conn.ssh_target()); let status = Command::new("ssh") .args(["-p", &conn.port.to_string(), &conn.ssh_target()]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status()?; if !status.success() { bail!("SSH connection failed"); } Ok(()) } /// Set the host connection. fn set_host(connection: &str) -> Result<()> { let conn = HostConnection::parse(connection)?; let mut config = load_deploy_config()?; config.host = Some(conn.clone()); save_deploy_config(&config)?; println!("✓ Host set: {}@{}:{}", conn.user, conn.host, conn.port); println!("\nTest connection: f deploy shell"); Ok(()) } /// Show current host. fn show_host() -> Result<()> { let config = load_deploy_config()?; if let Some(conn) = &config.host { println!("Host: {}@{}:{}", conn.user, conn.host, conn.port); } else { println!("No host configured."); println!("Set one with: f deploy set-host user@host:port"); } Ok(()) } // ───────────────────────────────────────────────────────────── // SSH/rsync helpers // ───────────────────────────────────────────────────────────── /// Run SSH command with inherited stdio. fn ssh_run(conn: &HostConnection, cmd: &str) -> Result<()> { let status = Command::new("ssh") .args([ "-p", &conn.port.to_string(), "-o", "StrictHostKeyChecking=accept-new", &conn.ssh_target(), cmd, ]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("Failed to run SSH")?; if !status.success() { bail!("SSH command failed: {}", cmd); } Ok(()) } /// Run SSH command and capture output. fn ssh_capture(conn: &HostConnection, cmd: &str) -> Result<String> { let output = Command::new("ssh") .args([ "-p", &conn.port.to_string(), "-o", "StrictHostKeyChecking=accept-new", &conn.ssh_target(), cmd, ]) .output() .context("Failed to run SSH")?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) } /// Sync directory via rsync. fn rsync_upload(local: &Path, conn: &HostConnection, remote_dest: &str) -> Result<()> { let remote = format!("{}:{}", conn.ssh_target(), remote_dest); let ssh_cmd = format!("ssh -p {}", conn.port); // Create remote directory first ssh_run(conn, &format!("mkdir -p {}", remote_dest))?; let status = Command::new("rsync") .args([ "-avz", "--delete", "--exclude=target/", "--exclude=.git/", "--exclude=node_modules/", "--exclude=.env", "--exclude=*.log", "-e", &ssh_cmd, &format!("{}/", local.display()), &remote, ]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("Failed to run rsync")?; if !status.success() { bail!("rsync failed"); } Ok(()) } /// Copy file via scp. fn scp_file(local: &Path, conn: &HostConnection, remote: &str) -> Result<()> { let dest = format!("{}:{}", conn.ssh_target(), remote); let status = Command::new("scp") .args([ "-P", &conn.port.to_string(), &local.display().to_string(), &dest, ]) .status() .context("Failed to run scp")?; if !status.success() { bail!("scp failed"); } Ok(()) } /// Install the env-fetch script on the host. /// This script fetches env vars from cloud using a service token on startup. fn install_env_fetch_script( conn: &HostConnection, dest: &str, service_token: &str, api_base: &str, project_name: &str, environment: &str, keys: &[String], ) -> Result<()> { // Build the keys query parameter let keys_param = if keys.is_empty() { String::new() } else { format!("&keys={}", keys.join(",")) }; // Create the fetch script // The script fetches env vars from cloud API and writes to .env let api_base = api_base.trim_end_matches('/'); let script = format!( r##"#!/bin/bash # Auto-generated by flow - fetches env vars from cloud on startup # This token can ONLY read env vars for project: {project_name} set -e TOKEN_FILE="{dest}/.cloud-token" ENV_FILE="{dest}/.env" API_URL="{api_base}/api/env/{project_name}?environment={environment}{keys_param}" if [ ! -f "$TOKEN_FILE" ]; then echo "ERROR: Service token not found at $TOKEN_FILE" >&2 exit 1 fi TOKEN=$(cat "$TOKEN_FILE") # Fetch env vars from cloud RESPONSE=$(curl -sf -H "Authorization: Bearer $TOKEN" "$API_URL") if [ $? -ne 0 ]; then echo "ERROR: Failed to fetch env vars from cloud" >&2 exit 1 fi # Parse JSON and write to .env file echo "# Environment: {environment} (fetched from cloud)" > "$ENV_FILE" echo "$RESPONSE" | python3 -c " import json, sys data = json.load(sys.stdin) for k, v in sorted(data.get('env', {{}}).items()): escaped = v.replace('\\', '\\\\').replace('\"', '\\\"') print(f'{{{{k}}}}=\"{{{{escaped}}}}\"') " >> "$ENV_FILE" chmod 600 "$ENV_FILE" echo "Fetched env vars for {project_name} ({environment})" "## ); // Write script to temp file and copy let temp_script = std::env::temp_dir().join(format!("fetch-env-{}.sh", std::process::id())); fs::write(&temp_script, &script)?; scp_file(&temp_script, conn, &format!("{}/fetch-env.sh", dest))?; let _ = fs::remove_file(&temp_script); // Make executable ssh_run(conn, &format!("chmod +x {}/fetch-env.sh", dest))?; // Store the service token securely let temp_token = std::env::temp_dir().join(format!(".cloud-token-{}", std::process::id())); fs::write(&temp_token, service_token)?; scp_file(&temp_token, conn, &format!("{}/.cloud-token", dest))?; let _ = fs::remove_file(&temp_token); // Secure the token file (only readable by root) ssh_run(conn, &format!("chmod 600 {}/.cloud-token", dest))?; Ok(()) } /// Check if systemd service exists. fn service_exists(conn: &HostConnection, name: &str) -> Result<bool> { let output = ssh_capture( conn, &format!( "systemctl list-unit-files {} 2>/dev/null | grep -c {} || true", name, name ), )?; Ok(output.trim() != "0") } /// Create systemd service file. fn create_systemd_service( conn: &HostConnection, name: &str, workdir: &str, exec_start: &str, config: &HostConfig, ) -> Result<()> { let exec_start = normalize_exec_start(workdir, exec_start); // Determine if we're using cloud with service token (fetch on startup) let use_cloud = is_cloud_source(config.env_source.as_deref()); let has_service_token = config.service_token.is_some(); let env_file_line = if use_cloud || config.env_file.is_some() { format!("EnvironmentFile={}/.env", workdir) } else { String::new() }; // Add ExecStartPre to fetch env vars if using service token let exec_start_pre = if use_cloud && has_service_token { format!("ExecStartPre={}/fetch-env.sh", workdir) } else { String::new() }; let service = format!( r#"[Unit] Description={name} After=network.target [Service] Type=simple WorkingDirectory={workdir} {exec_start_pre} ExecStart={exec_start} Restart=always RestartSec=5 {env_file_line} [Install] WantedBy=multi-user.target "# ); let escaped = service.replace('\"', "\\\"").replace('$', "\\$"); let cmd = format!( "echo \"{}\" > /etc/systemd/system/{}.service && systemctl daemon-reload && systemctl enable {}", escaped, name, name ); ssh_run(conn, &cmd)?; Ok(()) } fn normalize_exec_start(workdir: &str, exec_start: &str) -> String { let trimmed = exec_start.trim(); if trimmed.is_empty() { return String::new(); } let mut parts = shell_words::split(trimmed) .unwrap_or_else(|_| trimmed.split_whitespace().map(|s| s.to_string()).collect()); if parts.is_empty() { return trimmed.to_string(); } let cmd = parts[0].as_str(); if cmd.starts_with('/') { return trimmed.to_string(); } if cmd.starts_with("./") || cmd.starts_with("../") || cmd.contains('/') { let abs = Path::new(workdir).join(cmd).to_string_lossy().to_string(); parts[0] = abs; return shell_words::join(parts); } let mut env_parts = Vec::with_capacity(parts.len() + 1); env_parts.push("/usr/bin/env".to_string()); env_parts.extend(parts); shell_words::join(env_parts) } /// Set up nginx reverse proxy. fn setup_nginx(conn: &HostConnection, domain: &str, port: u16, ssl: bool) -> Result<()> { let config = format!( r#"server {{ listen 80; server_name {domain}; location / {{ proxy_pass http://127.0.0.1:{port}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; }} }} "# ); let escaped = config.replace('\"', "\\\"").replace('$', "\\$"); let cmd = format!( "echo \"{}\" > /etc/nginx/sites-available/{} && \ ln -sf /etc/nginx/sites-available/{} /etc/nginx/sites-enabled/ && \ nginx -t && systemctl reload nginx", escaped, domain, domain ); ssh_run(conn, &cmd)?; // Set up SSL if requested if ssl { println!("==> Setting up SSL certificate..."); let ssl_cmd = format!( "certbot --nginx -d {} --non-interactive --agree-tos -m admin@{} || true", domain, domain ); ssh_run(conn, &ssl_cmd)?; } Ok(()) } /// Set Cloudflare Worker secrets from env file. fn set_wrangler_secrets( worker_path: &Path, env_file: &Path, env_name: Option<&str>, selected_keys: Option<&[String]>, ) -> Result<()> { let content = fs::read_to_string(env_file)?; let vars = parse_env_file(&content); let allowlist = selected_keys.map(|keys| keys.iter().cloned().collect::<HashSet<String>>()); for (key, value) in vars { if let Some(allowlist) = &allowlist { if !allowlist.contains(&key) { continue; } } println!(" Setting {}...", key); set_wrangler_secret_value(worker_path, env_name, &key, &value)?; } Ok(()) } fn append_env_arg(cmd: &str, env_name: Option<&str>) -> String { if let Some(env) = env_name { if cmd.contains("--env") { cmd.to_string() } else { format!("{cmd} --env {env}") } } else { cmd.to_string() } } fn update_flow_toml_cloudflare( flow_path: &Path, project_root: &Path, setup: &CloudflareSetupResult, ) -> Result<()> { let contents = fs::read_to_string(flow_path)?; let mut lines: Vec<String> = contents.lines().map(|line| line.to_string()).collect(); let had_trailing_newline = contents.ends_with('\n'); let worker_path = relative_dir(project_root, &setup.worker_path); let env_file = setup .env_file .as_ref() .map(|path| relative_path(project_root, path)); let environment = setup.environment.clone(); if let Some(start) = lines.iter().position(|line| line.trim() == "[cloudflare]") { let end = find_section_end(&lines, start + 1); let mut section_lines = Vec::new(); for line in &lines[start + 1..end] { if !is_cloudflare_key_line(line) { section_lines.push(line.clone()); } } let mut updates = Vec::new(); if let Some(path) = worker_path { updates.push(format!("path = \"{}\"", path)); } if let Some(env_file) = env_file { updates.push(format!("env_file = \"{}\"", env_file)); } if let Some(environment) = environment { updates.push(format!("environment = \"{}\"", environment)); } if !updates.is_empty() { let needs_blank = section_lines .last() .map(|line| !line.trim().is_empty()) .unwrap_or(false); if needs_blank { section_lines.push(String::new()); } section_lines.extend(updates); } let mut updated = Vec::new(); updated.extend_from_slice(&lines[..start + 1]); updated.extend(section_lines); updated.extend_from_slice(&lines[end..]); lines = updated; } else { if !lines.is_empty() && !lines .last() .map(|line| line.trim().is_empty()) .unwrap_or(false) { lines.push(String::new()); } lines.push("[cloudflare]".to_string()); if let Some(path) = worker_path { lines.push(format!("path = \"{}\"", path)); } if let Some(env_file) = env_file { lines.push(format!("env_file = \"{}\"", env_file)); } if let Some(environment) = environment { lines.push(format!("environment = \"{}\"", environment)); } } let mut updated = lines.join("\n"); if had_trailing_newline { updated.push('\n'); } fs::write(flow_path, updated)?; Ok(()) } fn find_section_end(lines: &[String], start: usize) -> usize { for (idx, line) in lines.iter().enumerate().skip(start) { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { return idx; } } lines.len() } fn is_cloudflare_key_line(line: &str) -> bool { let trimmed = line.trim(); if trimmed.starts_with('#') || trimmed.starts_with(';') { return false; } let Some((key, _)) = trimmed.split_once('=') else { return false; }; matches!(key.trim(), "path" | "env_file" | "environment" | "env") } fn relative_path(project_root: &Path, path: &Path) -> String { path.strip_prefix(project_root) .unwrap_or(path) .to_string_lossy() .to_string() } fn ensure_web_config(flow_path: &Path, project_root: &Path, cfg: &Config) -> Result<bool> { let existing_path = cfg.web.as_ref().and_then(|web| web.path.clone()); if existing_path.is_some() { return Ok(false); } let web_path = match detect_web_path(project_root)? { Some(path) => path, None => { if !std::io::stdin().is_terminal() { bail!( "No [web] section found and unable to infer web path. Add [web] path = \"...\"." ); } let input = prompt_line("Web path (relative to repo root)", None)?; if input.trim().is_empty() { bail!("Web path required. Add [web] path = \"...\" in flow.toml."); } input } }; ensure_web_path(flow_path, &web_path) } fn ensure_web_domain_or_route(flow_path: &Path, web_cfg: &WebConfig) -> Result<bool> { if web_cfg.domain.is_some() || web_cfg.route.is_some() { return Ok(false); } if !std::io::stdin().is_terminal() { bail!("web.domain or web.route is required in flow.toml."); } println!("Web routing setup"); println!("-----------------"); let domain = prompt_line("Domain (e.g., example.com)", None)?; if !domain.trim().is_empty() { return ensure_web_key(flow_path, "domain", &domain); } let route = prompt_line("Route (e.g., example.com/*)", None)?; if route.trim().is_empty() { bail!("web.domain or web.route is required to deploy web."); } ensure_web_key(flow_path, "route", &route) } fn ensure_web_env_source(flow_path: &Path, web_cfg: &WebConfig) -> Result<bool> { if web_cfg.env_source.is_some() { return Ok(false); } if !std::io::stdin().is_terminal() { return Ok(false); } if prompt_yes_no("Use cloud for web env vars?", true)? { let mut changed = false; if ensure_web_key(flow_path, "env_source", "cloud")? { changed = true; } if ensure_web_key(flow_path, "env_apply", "always")? { changed = true; } return Ok(changed); } if prompt_yes_no("Use local env store instead?", true)? { let mut changed = false; if ensure_web_key(flow_path, "env_source", "local")? { changed = true; } if ensure_web_key(flow_path, "env_apply", "always")? { changed = true; } return Ok(changed); } Ok(false) } fn detect_web_path(project_root: &Path) -> Result<Option<String>> { let packages_web = project_root.join("packages").join("web"); if packages_web.join("wrangler.jsonc").exists() || packages_web.join("wrangler.json").exists() || packages_web.join("wrangler.toml").exists() { return Ok(Some("packages/web".to_string())); } if project_root.join("wrangler.jsonc").exists() || project_root.join("wrangler.json").exists() || project_root.join("wrangler.toml").exists() { return Ok(Some(".".to_string())); } let configs = discover_wrangler_configs(project_root)?; if configs.len() == 1 { let rel = relative_path(project_root, &configs[0]); if rel.is_empty() { return Ok(Some(".".to_string())); } return Ok(Some(rel)); } Ok(None) } fn ensure_web_path(flow_path: &Path, web_path: &str) -> Result<bool> { ensure_web_key(flow_path, "path", web_path) } fn ensure_web_key(flow_path: &Path, key: &str, value: &str) -> Result<bool> { let contents = fs::read_to_string(flow_path)?; let mut lines: Vec<String> = contents.lines().map(|line| line.to_string()).collect(); let had_trailing_newline = contents.ends_with('\n'); let mut changed = false; if let Some(start) = lines.iter().position(|line| line.trim() == "[web]") { let end = find_section_end(&lines, start + 1); let mut section_lines = lines[start + 1..end].to_vec(); if !section_has_key(§ion_lines, key) { section_lines.push(format!("{key} = \"{}\"", value.trim())); changed = true; } let mut updated = Vec::new(); updated.extend_from_slice(&lines[..start + 1]); updated.extend(section_lines); updated.extend_from_slice(&lines[end..]); lines = updated; } else { if !lines.is_empty() && !lines .last() .map(|line| line.trim().is_empty()) .unwrap_or(false) { lines.push(String::new()); } lines.push("[web]".to_string()); lines.push(format!("{key} = \"{}\"", value.trim())); changed = true; } if changed { let mut updated = lines.join("\n"); if had_trailing_newline { updated.push('\n'); } fs::write(flow_path, updated)?; } Ok(changed) } fn section_has_key(lines: &[String], key: &str) -> bool { let key_prefix = format!("{key} "); let key_eq = format!("{key}="); lines.iter().any(|line| { let trimmed = line.trim(); trimmed.starts_with(&key_prefix) || trimmed.starts_with(&key_eq) }) } fn ensure_web_routes(project_root: &Path, web_cfg: &WebConfig) -> Result<bool> { let Some(route) = resolve_web_route(web_cfg) else { eprintln!("WARN web route not set. Add web.route or web.domain in flow.toml."); return Ok(false); }; let web_path = web_cfg.path.as_deref().unwrap_or("."); let web_root = project_root.join(web_path); ensure_wrangler_config(&web_root)?; let Some(config_path) = find_wrangler_route_file(&web_root) else { eprintln!( "WARN No wrangler.json/jsonc found in {}; add route manually.", web_root.display() ); return Ok(false); }; ensure_wrangler_routes_jsonc(&config_path, &route) } fn ensure_web_dns(web_cfg: &WebConfig) -> Result<()> { let Some(domain) = resolve_web_domain(web_cfg) else { return Ok(()); }; if !std::io::stdin().is_terminal() { return Ok(()); } println!("DNS setup"); println!("---------"); println!("Domain: {}", domain); if !prompt_yes_no("Manage DNS record in Cloudflare?", true)? { return Ok(()); } let token = std::env::var("CLOUDFLARE_API_TOKEN") .context("Cloudflare API token missing. Run `f env new` -> Cloudflare token.")?; let client = cloudflare_api_client()?; let lookup_domain = domain.trim_start_matches("*."); let Some((zone_id, zone_name)) = find_cloudflare_zone(&client, &token, lookup_domain)? else { eprintln!("WARN No Cloudflare zone found for {}.", lookup_domain); return Ok(()); }; let record_type = prompt_line("DNS record type (A or CNAME)", Some("A"))?; let record_type = record_type.trim().to_ascii_uppercase(); if record_type.is_empty() { bail!("DNS record type required."); } let default_target = if record_type == "CNAME" { zone_name.clone() } else { "192.0.2.1".to_string() }; let target = prompt_line("DNS record target", Some(&default_target))?; let target = target.trim(); if target.is_empty() { bail!("DNS record target required."); } let proxied = prompt_yes_no("Proxy through Cloudflare?", true)?; upsert_cloudflare_dns_record( &client, &token, &zone_id, &domain, &record_type, target, proxied, )?; println!("OK DNS record configured for {}", domain); Ok(()) } fn resolve_web_route(web_cfg: &WebConfig) -> Option<String> { if let Some(route) = web_cfg.route.as_ref() { return Some(route.clone()); } web_cfg .domain .as_ref() .map(|domain| format!("{}/*", domain.trim())) } fn resolve_web_domain(web_cfg: &WebConfig) -> Option<String> { if let Some(domain) = web_cfg.domain.as_ref() { let trimmed = domain.trim(); if trimmed.is_empty() { return None; } return Some(trimmed.to_string()); } let route = web_cfg.route.as_ref()?.trim(); if route.is_empty() { return None; } let route = route .trim_start_matches("https://") .trim_start_matches("http://"); let host = route.split('/').next().unwrap_or(route).trim(); if host.is_empty() || host == "*" { return None; } let host = host.trim_end_matches("/*").trim_end_matches('/'); if host.is_empty() { return None; } Some(host.to_string()) } fn resolve_prod_route(prod_cfg: &ProdConfig) -> Option<String> { if let Some(route) = prod_cfg.route.as_ref() { let route = route.trim(); if !route.is_empty() { return Some(route.to_string()); } } let domain = prod_cfg.domain.as_ref()?.trim(); if domain.is_empty() { return None; } let domain = domain .trim_start_matches("https://") .trim_start_matches("http://") .trim_end_matches('/'); if domain.is_empty() || domain == "*" { return None; } Some(format!("{}/*", domain)) } fn ensure_prod_cloudflare_routes(project_root: &Path, config: &Config) -> Result<()> { let Some(prod_cfg) = config.prod.as_ref() else { return Ok(()); }; let Some(cf_cfg) = config.cloudflare.as_ref() else { return Ok(()); }; let Some(route) = resolve_prod_route(prod_cfg) else { return Ok(()); }; let worker_root = cf_cfg .path .as_ref() .map(|p| project_root.join(p)) .unwrap_or_else(|| project_root.to_path_buf()); let Some(config_path) = find_wrangler_route_file(&worker_root) else { eprintln!( "WARN No wrangler.json/jsonc found in {}; add route '{}' manually.", worker_root.display(), route ); return Ok(()); }; if ensure_wrangler_routes_jsonc(&config_path, &route)? { println!("Added prod route '{}' to {}", route, config_path.display()); } if ensure_wrangler_bool_jsonc(&config_path, "workers_dev", true)? { println!("Enabled workers_dev in {}", config_path.display()); } if ensure_wrangler_bool_jsonc(&config_path, "preview_urls", true)? { println!("Enabled preview_urls in {}", config_path.display()); } Ok(()) } fn find_wrangler_route_file(web_root: &Path) -> Option<PathBuf> { let jsonc = web_root.join("wrangler.jsonc"); if jsonc.exists() { return Some(jsonc); } let json = web_root.join("wrangler.json"); if json.exists() { return Some(json); } None } fn apply_web_env(project_root: &Path, web_cfg: &WebConfig) -> Result<()> { let env_apply_mode = env_apply_mode_from_str(web_cfg.env_apply.as_deref()); if env_apply_mode == EnvApplyMode::Never { return Ok(()); } let source = web_cfg.env_source.as_deref(); if !is_cloud_source(source) && !is_local_source(source) { return Ok(()); } if is_local_source(source) { unsafe { std::env::set_var("FLOW_ENV_BACKEND", "local"); } } let keys = collect_web_env_keys(web_cfg); if keys.is_empty() { return Ok(()); } let env_name = web_cfg.environment.as_deref().unwrap_or("production"); let mut vars: HashMap<String, String> = HashMap::new(); for key in &keys { if let Some(value) = web_cfg.env_defaults.get(key) { if !value.trim().is_empty() { vars.insert(key.clone(), value.clone()); } } } match crate::env::fetch_project_env_vars(env_name, &keys) { Ok(fetched) => { vars.extend(fetched); } Err(err) => { if env_apply_mode == EnvApplyMode::Auto { eprintln!("WARN env sync skipped: {err}"); return Ok(()); } return Err(err); } } let web_path = web_cfg.path.as_deref().unwrap_or("."); let web_root = project_root.join(web_path); ensure_wrangler_config(&web_root)?; let var_keys: HashSet<String> = web_cfg.env_vars.iter().cloned().collect(); set_wrangler_env_map(&web_root, web_cfg.environment.as_deref(), &vars, &var_keys)?; Ok(()) } fn collect_web_env_keys(web_cfg: &WebConfig) -> Vec<String> { let mut keys = Vec::new(); let mut seen = HashSet::new(); for key in web_cfg.env_keys.iter().chain(web_cfg.env_vars.iter()) { if seen.insert(key.clone()) { keys.push(key.clone()); } } keys } fn is_local_source(source: Option<&str>) -> bool { matches!( source.map(|s| s.to_ascii_lowercase()).as_deref(), Some("local") ) } fn ensure_cloudflare_api_token() -> Result<()> { if std::env::var("CLOUDFLARE_API_TOKEN") .map(|value| !value.trim().is_empty()) .unwrap_or(false) { return Ok(()); } let key = "CLOUDFLARE_API_TOKEN".to_string(); let mut token = fetch_personal_env_value(&key)?; if token.is_none() && std::io::stdin().is_terminal() { println!("Cloudflare API token required for deploy."); println!("How to get it:"); println!(" - Open https://dash.cloudflare.com/profile/api-tokens"); println!(" - Create a token (Template: Edit Cloudflare Workers or Custom)"); println!(" - Permissions: Workers Scripts:Edit, Workers Routes:Edit, Pages:Edit"); println!(" - Add Zone:Read + DNS:Edit for your domain"); println!(" - Copy the token value"); println!(); if !prompt_yes_no("Save token now?", true)? { bail!("Cloudflare token required to deploy."); } let default_store = if wants_local_env_backend() { "local" } else { "cloud" }; let store = prompt_line("Store token in (cloud/local)", Some(default_store))?; let store = store.trim().to_ascii_lowercase(); let store_local = matches!(store.as_str(), "local" | "l"); let store_cloud = matches!(store.as_str(), "cloud" | "c"); if !store_local && !store_cloud { bail!("Store token in cloud or local."); } let input = prompt_secret("Enter Cloudflare API token (input hidden): ")?; if input.trim().is_empty() { bail!("Cloudflare token required to deploy."); } if store_local { with_local_env_backend(|| crate::env::set_personal_env_var(&key, input.trim()))?; } else { crate::env::set_personal_env_var(&key, input.trim())?; } token = Some(input); println!("Saved {} to env store.", key); } let Some(token) = token else { bail!( "Cloudflare API token required. Store it as personal env key {}.", key ); }; unsafe { std::env::set_var("CLOUDFLARE_API_TOKEN", token.trim()); } Ok(()) } fn wants_local_env_backend() -> bool { if let Some(backend) = crate::config::preferred_env_backend() { return backend == "local"; } if let Ok(value) = std::env::var("FLOW_ENV_BACKEND") { return value.trim().eq_ignore_ascii_case("local"); } std::env::var("FLOW_ENV_LOCAL") .ok() .map(|value| value.trim() == "1" || value.trim().eq_ignore_ascii_case("true")) .unwrap_or(false) } fn with_local_env_backend<T>(action: impl FnOnce() -> Result<T>) -> Result<T> { let previous = std::env::var("FLOW_ENV_BACKEND").ok(); unsafe { std::env::set_var("FLOW_ENV_BACKEND", "local"); } let result = action(); unsafe { match previous { Some(value) => std::env::set_var("FLOW_ENV_BACKEND", value), None => std::env::remove_var("FLOW_ENV_BACKEND"), } } result } fn cloudflare_api_client() -> Result<Client> { Client::builder() .timeout(Duration::from_secs(20)) .build() .context("failed to build Cloudflare API client") } fn find_cloudflare_zone( client: &Client, token: &str, domain: &str, ) -> Result<Option<(String, String)>> { for candidate in cloudflare_zone_candidates(domain) { let resp = client .get("https://api.cloudflare.com/client/v4/zones") .bearer_auth(token) .query(&[("name", candidate.as_str()), ("status", "active")]) .send() .context("failed to query Cloudflare zones")?; let json: Value = resp .json() .context("failed to parse Cloudflare zones response")?; cloudflare_api_check(&json, "listing zones")?; if let Some(zone) = json["result"].as_array().and_then(|arr| arr.first()) { if let (Some(id), Some(name)) = (zone["id"].as_str(), zone["name"].as_str()) { return Ok(Some((id.to_string(), name.to_string()))); } } } Ok(None) } fn cloudflare_zone_candidates(domain: &str) -> Vec<String> { let trimmed = domain.trim().trim_end_matches('.'); let parts: Vec<&str> = trimmed.split('.').filter(|part| !part.is_empty()).collect(); if parts.len() < 2 { return vec![trimmed.to_string()]; } let mut candidates = Vec::new(); for i in 0..parts.len() - 1 { let candidate = parts[i..].join("."); if candidate.split('.').count() >= 2 { candidates.push(candidate); } } candidates } fn upsert_cloudflare_dns_record( client: &Client, token: &str, zone_id: &str, domain: &str, record_type: &str, target: &str, proxied: bool, ) -> Result<()> { if let Some(existing) = fetch_cloudflare_dns_record(client, token, zone_id, domain, record_type)? { if existing.content == target && existing.proxied == proxied { println!("OK DNS record already set for {}", domain); return Ok(()); } let url = format!( "https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", zone_id, existing.id ); let resp = client .put(&url) .bearer_auth(token) .json(&serde_json::json!({ "type": record_type, "name": domain, "content": target, "proxied": proxied, "ttl": 1, })) .send() .context("failed to update Cloudflare DNS record")?; let json: Value = resp.json().context("failed to parse DNS update response")?; cloudflare_api_check(&json, "updating DNS record")?; return Ok(()); } let url = format!( "https://api.cloudflare.com/client/v4/zones/{}/dns_records", zone_id ); let resp = client .post(&url) .bearer_auth(token) .json(&serde_json::json!({ "type": record_type, "name": domain, "content": target, "proxied": proxied, "ttl": 1, })) .send() .context("failed to create Cloudflare DNS record")?; let json: Value = resp.json().context("failed to parse DNS create response")?; cloudflare_api_check(&json, "creating DNS record")?; Ok(()) } struct CloudflareDnsRecord { id: String, content: String, proxied: bool, } fn fetch_cloudflare_dns_record( client: &Client, token: &str, zone_id: &str, domain: &str, record_type: &str, ) -> Result<Option<CloudflareDnsRecord>> { let url = format!( "https://api.cloudflare.com/client/v4/zones/{}/dns_records", zone_id ); let resp = client .get(&url) .bearer_auth(token) .query(&[("type", record_type), ("name", domain)]) .send() .context("failed to query Cloudflare DNS records")?; let json: Value = resp.json().context("failed to parse DNS record response")?; cloudflare_api_check(&json, "listing DNS records")?; let Some(record) = json["result"].as_array().and_then(|arr| arr.first()) else { return Ok(None); }; let id = record["id"].as_str().unwrap_or_default().to_string(); if id.is_empty() { return Ok(None); } let content = record["content"].as_str().unwrap_or_default().to_string(); let proxied = record["proxied"].as_bool().unwrap_or(false); Ok(Some(CloudflareDnsRecord { id, content, proxied, })) } fn cloudflare_api_check(payload: &Value, action: &str) -> Result<()> { if payload["success"].as_bool().unwrap_or(false) { return Ok(()); } let message = payload["errors"] .as_array() .and_then(|errs| errs.first()) .and_then(|err| err.get("message")) .and_then(|value| value.as_str()) .unwrap_or("Unknown error"); bail!("Cloudflare API error while {}: {}", action, message) } fn fetch_personal_env_value(key: &str) -> Result<Option<String>> { let keys = vec![key.to_string()]; match crate::env::fetch_personal_env_vars(&keys) { Ok(vars) => Ok(vars.get(key).cloned()), Err(err) => { if is_not_logged_in_err(&err) || is_cloud_unavailable(&err) { return Ok(None); } Err(err) } } } fn is_not_logged_in_err(err: &anyhow::Error) -> bool { err.to_string() .to_ascii_lowercase() .contains("not logged in") } fn is_cloud_unavailable(err: &anyhow::Error) -> bool { err.to_string() .to_ascii_lowercase() .contains("failed to connect to cloud") } fn ensure_wrangler_routes_jsonc(path: &Path, route: &str) -> Result<bool> { let contents = fs::read_to_string(path)?; if contents.contains(route) { return Ok(false); } if contents.contains("\"routes\"") { eprintln!( "WARN {} has routes configured; add '{}' manually if needed.", path.display(), route ); return Ok(false); } let insert_block = format!("\"routes\": [\n \"{}\"\n]", route); let mut lines: Vec<String> = contents.lines().map(|line| line.to_string()).collect(); let had_trailing_newline = contents.ends_with('\n'); if let Some(pos) = lines.iter().rposition(|line| line.trim() == "}") { let needs_comma = lines .iter() .take(pos) .rfind(|line| !line.trim().is_empty()) .map(|line| !line.trim_end().ends_with(',') && !line.trim_end().ends_with('{')) .unwrap_or(false); if needs_comma { if let Some(last) = lines .iter_mut() .take(pos) .rfind(|line| !line.trim().is_empty()) { if !last.trim_end().ends_with(',') { last.push(','); } } } let mut block_lines: Vec<String> = insert_block .lines() .map(|line| format!(" {line}")) .collect(); lines.splice(pos..pos, block_lines.drain(..)); let mut updated = lines.join("\n"); if had_trailing_newline { updated.push('\n'); } fs::write(path, updated)?; return Ok(true); } Ok(false) } fn ensure_wrangler_bool_jsonc(path: &Path, key: &str, value: bool) -> Result<bool> { let contents = fs::read_to_string(path)?; let needle = format!("\"{key}\""); if contents.contains(&needle) { return Ok(false); } let insert_block = format!("\"{key}\": {}", if value { "true" } else { "false" }); let mut lines: Vec<String> = contents.lines().map(|line| line.to_string()).collect(); let had_trailing_newline = contents.ends_with('\n'); if let Some(pos) = lines.iter().rposition(|line| line.trim() == "}") { let needs_comma = lines .iter() .take(pos) .rfind(|line| !line.trim().is_empty()) .map(|line| !line.trim_end().ends_with(',') && !line.trim_end().ends_with('{')) .unwrap_or(false); if needs_comma { if let Some(last) = lines .iter_mut() .take(pos) .rfind(|line| !line.trim().is_empty()) { if !last.trim_end().ends_with(',') { last.push(','); } } } let mut block_lines: Vec<String> = insert_block .lines() .map(|line| format!(" {line}")) .collect(); lines.splice(pos..pos, block_lines.drain(..)); let mut updated = lines.join("\n"); if had_trailing_newline { updated.push('\n'); } fs::write(path, updated)?; return Ok(true); } Ok(false) } fn relative_dir(project_root: &Path, path: &Path) -> Option<String> { let rel = path.strip_prefix(project_root).unwrap_or(path); if rel.as_os_str().is_empty() || rel == Path::new(".") { None } else { Some(rel.to_string_lossy().to_string()) } } /// Set Railway environment variables from env file. fn set_railway_env(env_file: &Path) -> Result<()> { let content = fs::read_to_string(env_file)?; for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((key, value)) = line.split_once('=') { let value = value.trim_matches('"').trim_matches('\''); Command::new("railway") .args(["variables", "set", &format!("{}={}", key, value)]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status()?; } } Ok(()) } /// Check if deployment is healthy via HTTP. fn check_health( _project_root: &Path, config: Option<&Config>, custom_url: Option<String>, expected_status: u16, ) -> Result<()> { use std::time::Instant; // Determine URL to check let url = if let Some(url) = custom_url { url } else if let Some(config) = config { // Try host domain first if let Some(host) = &config.host { if let Some(domain) = &host.domain { let scheme = if host.ssl { "https" } else { "http" }; format!("{}://{}", scheme, domain) } else { bail!("No domain configured. Use --url to specify a URL to check."); } } else if let Some(cf) = &config.cloudflare { // Use configured URL if present if let Some(cf_url) = &cf.url { cf_url.clone() } else { bail!( "No URL configured in [cloudflare]. Add 'url = \"https://...\"' or use --url." ); } } else { bail!("No deployment config found. Use --url to specify a URL to check."); } } else { bail!("No flow.toml found. Use --url to specify a URL to check."); }; println!("Checking health: {}", url); let start = Instant::now(); // Use curl for simplicity (available everywhere) let output = Command::new("curl") .args([ "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", &url, ]) .output() .context("Failed to run curl")?; let elapsed = start.elapsed(); let status_str = String::from_utf8_lossy(&output.stdout); let actual_status: u16 = status_str.trim().parse().unwrap_or(0); if actual_status == expected_status { println!( "✓ Healthy (HTTP {} in {:.2}s)", actual_status, elapsed.as_secs_f64() ); Ok(()) } else if actual_status == 0 { let stderr = String::from_utf8_lossy(&output.stderr); bail!("✗ Unreachable: {}", stderr.trim()); } else { bail!( "✗ Unhealthy: expected HTTP {}, got {} ({:.2}s)", expected_status, actual_status, elapsed.as_secs_f64() ); } } ================================================ FILE: src/deploy_setup.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crossterm::{ event::{self, Event as CEvent, KeyCode, KeyEvent}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ignore::WalkBuilder; use ratatui::{ Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, }; use regex::Regex; use crate::env::parse_env_file; #[derive(Debug, Clone, Default)] pub struct CloudflareSetupDefaults { pub worker_path: Option<PathBuf>, pub env_file: Option<PathBuf>, pub environment: Option<String>, } #[derive(Debug, Clone)] pub struct CloudflareSetupResult { pub worker_path: PathBuf, pub env_file: Option<PathBuf>, pub environment: Option<String>, pub selected_keys: Vec<String>, pub apply_secrets: bool, } pub fn run_cloudflare_setup( project_root: &Path, defaults: CloudflareSetupDefaults, ) -> Result<Option<CloudflareSetupResult>> { let worker_paths = discover_wrangler_configs(project_root)?; if worker_paths.is_empty() { println!("No Cloudflare Worker config found (wrangler.toml/json)."); println!("Run `wrangler init` first, then try: f deploy setup"); return Ok(None); } let env_files = discover_env_files(project_root)?; let mut app = DeploySetupApp::new(project_root, worker_paths, env_files, defaults); enable_raw_mode().context("failed to enable raw mode")?; let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).context("failed to create terminal backend")?; let app_result = run_app(&mut terminal, &mut app); disable_raw_mode().ok(); let _ = terminal.show_cursor(); drop(terminal); let mut stdout = std::io::stdout(); execute!(stdout, LeaveAlternateScreen).ok(); app_result } #[derive(Debug, Clone, Copy)] enum SetupStep { Worker, EnvFile, EnvTarget, CustomEnv, Keys, Confirm, } struct EnvFileChoice { label: String, path: Option<PathBuf>, } struct EnvTargetChoice { label: String, value: Option<String>, is_custom: bool, } struct EnvKeyItem { key: String, selected: bool, suspect: bool, suspect_reason: Option<String>, value_len: usize, } struct DeploySetupApp { project_root: PathBuf, step: SetupStep, worker_paths: Vec<PathBuf>, selected_worker: usize, env_files: Vec<EnvFileChoice>, selected_env_file: usize, env_targets: Vec<EnvTargetChoice>, selected_env_target: usize, custom_env: String, key_items: Vec<EnvKeyItem>, selected_key: usize, apply_secrets: bool, result: Option<CloudflareSetupResult>, } impl DeploySetupApp { fn new( project_root: &Path, worker_paths: Vec<PathBuf>, env_files: Vec<PathBuf>, defaults: CloudflareSetupDefaults, ) -> Self { let selected_worker = pick_default_worker(&worker_paths, defaults.worker_path.as_ref()); let env_file_choices = build_env_file_choices(project_root, &env_files); let selected_env_file = pick_default_env_file_for_worker( &env_file_choices, &worker_paths[selected_worker], defaults.env_file.as_ref(), ); let mut app = Self { project_root: project_root.to_path_buf(), step: SetupStep::Worker, worker_paths, selected_worker, env_files: env_file_choices, selected_env_file, env_targets: Vec::new(), selected_env_target: 0, custom_env: String::new(), key_items: Vec::new(), selected_key: 0, apply_secrets: true, result: None, }; app.refresh_env_targets(defaults.environment.as_deref()); if matches!( app.env_targets.get(app.selected_env_target), Some(choice) if choice.is_custom ) { app.custom_env = defaults.environment.unwrap_or_default(); } app } fn worker_path(&self) -> &Path { &self.worker_paths[self.selected_worker] } fn refresh_env_targets(&mut self, preferred: Option<&str>) { let envs = extract_wrangler_envs(self.worker_path()); let mut targets = Vec::new(); targets.push(EnvTargetChoice { label: "production (default)".to_string(), value: None, is_custom: false, }); for env in envs { targets.push(EnvTargetChoice { label: env.clone(), value: Some(env), is_custom: false, }); } if let Some(env) = preferred { if !targets .iter() .any(|choice| choice.value.as_deref() == Some(env)) && env != "production" { targets.push(EnvTargetChoice { label: env.to_string(), value: Some(env.to_string()), is_custom: false, }); } } targets.push(EnvTargetChoice { label: "custom...".to_string(), value: None, is_custom: true, }); self.env_targets = targets; self.selected_env_target = pick_default_env_target(&self.env_targets, preferred); } fn select_env_file_for_worker(&mut self) { let worker_path = self.worker_path().to_path_buf(); if let Some(idx) = pick_env_file_for_worker(&self.env_files, &worker_path) { self.selected_env_file = idx; } } fn refresh_keys(&mut self) { self.key_items.clear(); self.selected_key = 0; if let Some(path) = self .env_files .get(self.selected_env_file) .and_then(|c| c.path.clone()) { if let Ok(items) = build_key_items(&path) { self.key_items = items; } } } fn env_file_path(&self) -> Option<PathBuf> { self.env_files .get(self.selected_env_file) .and_then(|choice| choice.path.clone()) } fn env_file_path_ref(&self) -> Option<&Path> { self.env_files .get(self.selected_env_file) .and_then(|choice| choice.path.as_deref()) } fn selected_env_target(&self) -> Option<String> { self.env_targets .get(self.selected_env_target) .and_then(|choice| choice.value.clone()) } fn finalize(&mut self) { let env_file = self.env_file_path(); let mut selected_keys = Vec::new(); if env_file.is_some() { selected_keys = self .key_items .iter() .filter(|item| item.selected) .map(|item| item.key.clone()) .collect(); } self.result = Some(CloudflareSetupResult { worker_path: self.worker_path().to_path_buf(), env_file, environment: self.selected_env_target(), selected_keys, apply_secrets: self.apply_secrets, }); } } fn run_app<B: ratatui::backend::Backend>( terminal: &mut Terminal<B>, app: &mut DeploySetupApp, ) -> Result<Option<CloudflareSetupResult>> { loop { terminal .draw(|f| draw_ui(f, app)) .map_err(|err| anyhow::anyhow!("failed to draw deploy setup UI: {err}"))?; if event::poll(std::time::Duration::from_millis(200))? { if let CEvent::Key(key) = event::read()? { if handle_key(app, key)? { return Ok(app.result.take()); } } } } } fn handle_key(app: &mut DeploySetupApp, key: KeyEvent) -> Result<bool> { match key.code { KeyCode::Char('q') => return Ok(true), KeyCode::Esc => return Ok(step_back(app)), _ => {} } match app.step { SetupStep::Worker => match key.code { KeyCode::Up => { select_prev(&mut app.selected_worker, app.worker_paths.len()); app.select_env_file_for_worker(); } KeyCode::Down => { select_next(&mut app.selected_worker, app.worker_paths.len()); app.select_env_file_for_worker(); } KeyCode::Enter => { app.refresh_env_targets(None); app.select_env_file_for_worker(); if app.env_files.len() <= 1 { app.step = SetupStep::EnvTarget; } else { app.step = SetupStep::EnvFile; } } _ => {} }, SetupStep::EnvFile => match key.code { KeyCode::Up => select_prev(&mut app.selected_env_file, app.env_files.len()), KeyCode::Down => select_next(&mut app.selected_env_file, app.env_files.len()), KeyCode::Enter => { app.step = SetupStep::EnvTarget; } _ => {} }, SetupStep::EnvTarget => match key.code { KeyCode::Up => select_prev(&mut app.selected_env_target, app.env_targets.len()), KeyCode::Down => select_next(&mut app.selected_env_target, app.env_targets.len()), KeyCode::Enter => { if app .env_targets .get(app.selected_env_target) .is_some_and(|choice| choice.is_custom) { app.custom_env.clear(); app.step = SetupStep::CustomEnv; } else if app.env_file_path().is_some() { app.refresh_keys(); if app.key_items.is_empty() { app.step = SetupStep::Confirm; } else { app.step = SetupStep::Keys; } } else { app.step = SetupStep::Confirm; } } _ => {} }, SetupStep::CustomEnv => match key.code { KeyCode::Enter => { if !app.custom_env.trim().is_empty() { app.env_targets.push(EnvTargetChoice { label: app.custom_env.trim().to_string(), value: Some(app.custom_env.trim().to_string()), is_custom: false, }); app.selected_env_target = app.env_targets.len().saturating_sub(2); if app.env_file_path().is_some() { app.refresh_keys(); app.step = if app.key_items.is_empty() { SetupStep::Confirm } else { SetupStep::Keys }; } else { app.step = SetupStep::Confirm; } } } KeyCode::Backspace => { app.custom_env.pop(); } KeyCode::Char(ch) => { if !ch.is_control() { app.custom_env.push(ch); } } _ => {} }, SetupStep::Keys => match key.code { KeyCode::Up => select_prev(&mut app.selected_key, app.key_items.len()), KeyCode::Down => select_next(&mut app.selected_key, app.key_items.len()), KeyCode::Char(' ') => { if let Some(item) = app.key_items.get_mut(app.selected_key) { item.selected = !item.selected; } } KeyCode::Enter => app.step = SetupStep::Confirm, _ => {} }, SetupStep::Confirm => match key.code { KeyCode::Char(' ') => app.apply_secrets = !app.apply_secrets, KeyCode::Enter => { app.finalize(); return Ok(true); } _ => {} }, } Ok(false) } fn draw_ui(f: &mut ratatui::Frame<'_>, app: &DeploySetupApp) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ] .as_ref(), ) .split(f.area()); let title = match app.step { SetupStep::Worker => "Deploy Setup: Cloudflare Workers", SetupStep::EnvFile => "Select .env file (optional)", SetupStep::EnvTarget => "Select Cloudflare environment", SetupStep::CustomEnv => "Enter custom environment", SetupStep::Keys => "Select secrets to push", SetupStep::Confirm => "Confirm setup", }; let header = Paragraph::new(Line::from(title)) .block(Block::default().borders(Borders::ALL).title("flow")) .alignment(ratatui::layout::Alignment::Center); f.render_widget(header, chunks[0]); match app.step { SetupStep::Worker => { let items = app .worker_paths .iter() .map(|path| { let label = relative_display(&app.project_root, path); ListItem::new(Line::from(label)) }) .collect::<Vec<_>>(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title("Worker path")) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.selected_worker)); f.render_stateful_widget(list, chunks[1], &mut state); } SetupStep::EnvFile => { let body = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)].as_ref()) .split(chunks[1]); let items = app .env_files .iter() .map(|choice| ListItem::new(Line::from(choice.label.clone()))) .collect::<Vec<_>>(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("Secrets source"), ) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.selected_env_file)); f.render_stateful_widget(list, body[0], &mut state); let preview_lines = build_env_preview_lines(&app.project_root, app.env_file_path_ref()); let preview = Paragraph::new(preview_lines) .block(Block::default().borders(Borders::ALL).title("Preview")) .wrap(Wrap { trim: true }); f.render_widget(preview, body[1]); } SetupStep::EnvTarget => { let items = app .env_targets .iter() .map(|choice| ListItem::new(Line::from(choice.label.clone()))) .collect::<Vec<_>>(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("Wrangler --env"), ) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.selected_env_target)); f.render_stateful_widget(list, chunks[1], &mut state); } SetupStep::CustomEnv => { let prompt = format!("> {}", app.custom_env); let input = Paragraph::new(prompt) .block( Block::default() .borders(Borders::ALL) .title("Environment name"), ) .wrap(Wrap { trim: true }); f.render_widget(input, chunks[1]); } SetupStep::Keys => { let body = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(60), Constraint::Percentage(40)].as_ref()) .split(chunks[1]); let selected_count = app.key_items.iter().filter(|item| item.selected).count(); let items = app .key_items .iter() .map(|item| { let indicator = if item.selected { "[x]" } else { "[ ]" }; let flag = if item.suspect { " suspect" } else { "" }; let label = format!("{indicator} {}{flag}", item.key); ListItem::new(Line::from(label)) }) .collect::<Vec<_>>(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!( "Secrets ({}/{})", selected_count, app.key_items.len() ))) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.selected_key)); f.render_stateful_widget(list, body[0], &mut state); let detail_lines = build_key_detail_lines( &app.project_root, app.env_file_path_ref(), app.key_items.get(app.selected_key), ); let details = Paragraph::new(detail_lines) .block(Block::default().borders(Borders::ALL).title("Details")) .wrap(Wrap { trim: true }); f.render_widget(details, body[1]); } SetupStep::Confirm => { let worker = relative_display(&app.project_root, app.worker_path()); let env_file = app .env_file_path() .map(|p| relative_display(&app.project_root, &p)) .unwrap_or_else(|| "none".to_string()); let env_target = app .selected_env_target() .unwrap_or_else(|| "production (default)".to_string()); let selected_count = app.key_items.iter().filter(|item| item.selected).count(); let apply = if app.apply_secrets { "yes" } else { "no" }; let summary = vec![ Line::from(vec![ Span::styled("Worker: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(worker), ]), Line::from(vec![ Span::styled("Env file: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(env_file), ]), Line::from(vec![ Span::styled( "Environment: ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(env_target), ]), Line::from(vec![ Span::styled( "Secrets selected: ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!("{}", selected_count)), ]), Line::from(vec![ Span::styled( "Apply secrets now: ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(apply), ]), ]; let paragraph = Paragraph::new(summary) .block(Block::default().borders(Borders::ALL).title("Review")) .wrap(Wrap { trim: true }); f.render_widget(paragraph, chunks[1]); } } let help = match app.step { SetupStep::Worker => "Up/Down to move, Enter to select, Esc to cancel, q to cancel", SetupStep::EnvFile => "Up/Down to move, Enter to select, Esc to back, q to cancel", SetupStep::EnvTarget => "Up/Down to move, Enter to select, Esc to back, q to cancel", SetupStep::CustomEnv => "Type name, Enter to confirm, Esc to back, q to cancel", SetupStep::Keys => { "Up/Down to move, Space to toggle, Enter to continue, Esc to back, q to cancel" } SetupStep::Confirm => "Space to toggle apply, Enter to finish, Esc to back, q to cancel", }; let footer = Paragraph::new(help) .block(Block::default().borders(Borders::ALL)) .alignment(ratatui::layout::Alignment::Center); f.render_widget(footer, chunks[2]); } fn build_env_preview_lines(project_root: &Path, env_file: Option<&Path>) -> Vec<Line<'static>> { let mut lines = Vec::new(); let Some(path) = env_file else { lines.push(Line::from("No env file selected.")); lines.push(Line::from("Secrets will not be set.")); return lines; }; lines.push(Line::from(vec![ Span::styled("File: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(relative_display(project_root, path)), ])); lines.push(Line::from("Values are hidden.")); let content = match fs::read_to_string(path) { Ok(content) => content, Err(_) => { lines.push(Line::from("Unable to read file.")); return lines; } }; let vars = parse_env_file(&content); if vars.is_empty() { lines.push(Line::from("No env vars found.")); return lines; } let mut entries: Vec<_> = vars.into_iter().collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); let suspect_count = entries .iter() .filter(|(_, value)| suspect_reason(value).is_some()) .count(); let total = entries.len(); lines.push(Line::from(format!( "Keys: {} (suspect: {})", total, suspect_count ))); lines.push(Line::from("! = likely test/local value")); let max_keys = 12usize; for (key, value) in entries.iter().take(max_keys) { let flag = if suspect_reason(value).is_some() { " !" } else { "" }; lines.push(Line::from(format!(" - {}{}", key, flag))); } if total > max_keys { lines.push(Line::from(format!("... +{} more", total - max_keys))); } lines } fn build_key_detail_lines( project_root: &Path, env_file: Option<&Path>, item: Option<&EnvKeyItem>, ) -> Vec<Line<'static>> { let mut lines = Vec::new(); let env_label = env_file .map(|path| relative_display(project_root, path)) .unwrap_or_else(|| "none".to_string()); lines.push(Line::from(format!("Env file: {}", env_label))); let Some(item) = item else { lines.push(Line::from("No key selected.")); return lines; }; lines.push(Line::from(format!("Key: {}", item.key))); lines.push(Line::from(format!( "Selected: {}", if item.selected { "yes" } else { "no" } ))); lines.push(Line::from(format!( "Status: {}", if item.suspect { "suspect" } else { "ok" } ))); if let Some(reason) = &item.suspect_reason { lines.push(Line::from(format!("Reason: {}", reason))); } lines.push(Line::from(format!("Value length: {}", item.value_len))); lines.push(Line::from("Values are hidden.")); if item.suspect { lines.push(Line::from("Tip: suspect values default to unchecked.")); } lines } fn select_prev(selected: &mut usize, len: usize) { if len == 0 { return; } if *selected == 0 { *selected = len.saturating_sub(1); } else { *selected -= 1; } } fn select_next(selected: &mut usize, len: usize) { if len == 0 { return; } if *selected + 1 >= len { *selected = 0; } else { *selected += 1; } } fn step_back(app: &mut DeploySetupApp) -> bool { match app.step { SetupStep::Worker => true, SetupStep::EnvFile => { app.step = SetupStep::Worker; false } SetupStep::EnvTarget => { if app.env_files.len() <= 1 { app.step = SetupStep::Worker; } else { app.step = SetupStep::EnvFile; } false } SetupStep::CustomEnv => { app.step = SetupStep::EnvTarget; false } SetupStep::Keys => { app.step = SetupStep::EnvTarget; false } SetupStep::Confirm => { if app.env_file_path().is_some() && !app.key_items.is_empty() { app.step = SetupStep::Keys; } else { app.step = SetupStep::EnvTarget; } false } } } fn relative_display(root: &Path, path: &Path) -> String { if let Ok(rel) = path.strip_prefix(root) { let rel = rel.to_string_lossy().to_string(); if rel.is_empty() { ".".to_string() } else { rel } } else { path.to_string_lossy().to_string() } } fn pick_default_worker(paths: &[PathBuf], preferred: Option<&PathBuf>) -> usize { if let Some(path) = preferred { if let Some((idx, _)) = paths.iter().enumerate().find(|(_, p)| *p == path) { return idx; } } 0 } fn build_env_file_choices(project_root: &Path, env_files: &[PathBuf]) -> Vec<EnvFileChoice> { let mut choices = Vec::new(); choices.push(EnvFileChoice { label: "Skip (do not set secrets)".to_string(), path: None, }); for path in env_files { choices.push(EnvFileChoice { label: relative_display(project_root, path), path: Some(path.clone()), }); } choices } fn pick_default_env_file_for_worker( choices: &[EnvFileChoice], worker_path: &Path, preferred: Option<&PathBuf>, ) -> usize { if let Some(path) = preferred { if let Some((idx, _)) = choices .iter() .enumerate() .find(|(_, c)| c.path.as_ref() == Some(path)) { return idx; } } if let Some(idx) = pick_env_file_for_worker(choices, worker_path) { return idx; } 0 } fn pick_env_file_for_worker(choices: &[EnvFileChoice], worker_path: &Path) -> Option<usize> { let candidates = [ ".env", ".env.cloudflare", ".env.production", ".env.staging", ".env.local", ]; for candidate in candidates { let candidate_path = worker_path.join(candidate); if let Some((idx, _)) = choices .iter() .enumerate() .find(|(_, c)| c.path.as_ref() == Some(&candidate_path)) { return Some(idx); } } None } fn pick_default_env_target(targets: &[EnvTargetChoice], preferred: Option<&str>) -> usize { if let Some(env) = preferred { if let Some((idx, _)) = targets .iter() .enumerate() .find(|(_, choice)| choice.value.as_deref() == Some(env)) { return idx; } } 0 } fn build_key_items(path: &Path) -> Result<Vec<EnvKeyItem>> { let content = fs::read_to_string(path) .with_context(|| format!("failed to read env file {}", path.display()))?; let env = parse_env_file(&content); let mut keys: Vec<_> = env.into_iter().collect(); keys.sort_by(|a, b| a.0.cmp(&b.0)); Ok(keys .into_iter() .map(|(key, value)| { let reason = suspect_reason(&value); let suspect = reason.is_some(); EnvKeyItem { key, selected: !suspect, suspect: suspect || value.trim().is_empty(), suspect_reason: reason.map(|reason| reason.to_string()), value_len: value.len(), } }) .collect()) } fn suspect_reason(value: &str) -> Option<&'static str> { let trimmed = value.trim(); if trimmed.is_empty() { return Some("empty"); } let lowered = trimmed.to_lowercase(); if lowered.contains("sk_test") || lowered.contains("pk_test") { return Some("stripe_test"); } if lowered.contains("localhost") || lowered.contains("127.0.0.1") { return Some("localhost"); } if lowered.contains("example.com") || lowered.contains("example") { return Some("example"); } if lowered.contains("dummy") { return Some("dummy"); } if lowered.contains("test") { return Some("test"); } None } pub(crate) fn discover_wrangler_configs(root: &Path) -> Result<Vec<PathBuf>> { let walker = WalkBuilder::new(root) .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .max_depth(Some(10)) .filter_entry(|entry| { if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { let name = entry.file_name().to_string_lossy(); !matches!( name.as_ref(), "node_modules" | "target" | "dist" | "build" | ".git" | ".hg" | ".svn" | "__pycache__" | ".pytest_cache" | ".mypy_cache" | "venv" | ".venv" | "vendor" | "Pods" | ".cargo" | ".rustup" ) } else { true } }) .build(); let mut paths = Vec::new(); for entry in walker.flatten() { if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { if let Some(name) = entry.path().file_name().and_then(|s| s.to_str()) { if matches!(name, "wrangler.toml" | "wrangler.json" | "wrangler.jsonc") { if let Some(parent) = entry.path().parent() { paths.push(parent.to_path_buf()); } } } } } paths.sort(); paths.dedup(); Ok(paths) } fn discover_env_files(root: &Path) -> Result<Vec<PathBuf>> { let walker = WalkBuilder::new(root) .hidden(false) .git_ignore(false) .git_global(false) .git_exclude(false) .max_depth(Some(10)) .filter_entry(|entry| { if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { let name = entry.file_name().to_string_lossy(); !matches!( name.as_ref(), "node_modules" | "target" | "dist" | "build" | ".git" | ".hg" | ".svn" | "__pycache__" | ".pytest_cache" | ".mypy_cache" | "venv" | ".venv" | "vendor" | "Pods" | ".cargo" | ".rustup" ) } else { true } }) .build(); let mut env_files = Vec::new(); for entry in walker.flatten() { if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { if let Some(name) = entry.path().file_name().and_then(|s| s.to_str()) { if name.starts_with(".env") && name != ".envrc" { env_files.push(entry.path().to_path_buf()); } } } } env_files.sort(); env_files.dedup(); Ok(env_files) } fn extract_wrangler_envs(worker_path: &Path) -> Vec<String> { let toml_path = worker_path.join("wrangler.toml"); if toml_path.exists() { if let Ok(content) = fs::read_to_string(&toml_path) { let re = Regex::new(r"^\s*\[env\.([^\]]+)\]\s*$").unwrap(); let mut envs = Vec::new(); for line in content.lines() { if let Some(caps) = re.captures(line) { let env = caps.get(1).map(|m| m.as_str().trim().to_string()); if let Some(env) = env { if !env.is_empty() { envs.push(env); } } } } envs.sort(); envs.dedup(); return envs; } } Vec::new() } ================================================ FILE: src/deps.rs ================================================ use std::collections::{BTreeMap, BTreeSet}; use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use ignore::WalkBuilder; use serde::Deserialize; use toml::Value; use toml::map::Map; use crate::cli::{ DepsAction, DepsCommand, DepsEcosystem, DepsManager, ReposCloneOpts, UpdateDepsOpts, }; use crate::{config, opentui_prompt, repos, upstream}; pub fn run(cmd: DepsCommand) -> Result<()> { let action = cmd.action; let manager_override = cmd.manager; let project_root = project_root()?; match action { None | Some(DepsAction::Pick) => { pick_dependency(&project_root)?; } Some(DepsAction::Update(mut opts)) => { if opts.manager.is_none() { opts.manager = manager_override; } run_update_with_context(opts)?; } Some(DepsAction::Repo { repo, root, private, }) => { link_repo_dependency(&project_root, &repo, &root, private)?; } Some(other @ DepsAction::Install { .. }) => { let manager = manager_override.unwrap_or_else(|| detect_manager(&project_root)); let (program, args) = build_command(manager, &project_root, &other)?; let status = Command::new(program) .args(&args) .current_dir(&project_root) .status() .with_context(|| format!("failed to run {}", program))?; if !status.success() { bail!("dependency command failed"); } } } Ok(()) } fn run_update_with_context(opts: UpdateDepsOpts) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; let search_root = update_search_root(&cwd); let context = UpdateDetectContext { cwd, search_root }; let plans = build_update_plans(&context, &opts)?; if plans.is_empty() { bail!( "no dependency manifests found from {} up to {}", context.cwd.display(), context.search_root.display() ); } print_update_summary(&plans); if opts.dry_run { return Ok(()); } if !opts.yes && !confirm_update_plan(&opts, &plans)? { println!("dependency update canceled"); return Ok(()); } run_update_plans(&plans) } fn build_command( manager: DepsManager, project_root: &Path, action: &DepsAction, ) -> Result<(&'static str, Vec<String>)> { let workspace = is_workspace(project_root); let (base, mut args) = match (manager, workspace) { (DepsManager::Pnpm, true) => ("pnpm", vec!["-r".to_string()]), (DepsManager::Pnpm, false) => ("pnpm", Vec::new()), (DepsManager::Yarn, _) => ("yarn", Vec::new()), (DepsManager::Bun, _) => ("bun", Vec::new()), (DepsManager::Npm, _) => ("npm", Vec::new()), }; match action { DepsAction::Install { args: extra } => { args.push("install".to_string()); args.extend(extra.clone()); } DepsAction::Update(_) | DepsAction::Repo { .. } | DepsAction::Pick => { bail!("dependency action is not a package manager command"); } } Ok((base, args)) } fn detect_manager(project_root: &Path) -> DepsManager { if let Some(pm) = detect_manager_from_package_json(project_root) { return pm; } if project_root.join("pnpm-lock.yaml").exists() || project_root.join("pnpm-workspace.yaml").exists() { return DepsManager::Pnpm; } if project_root.join("bun.lockb").exists() || project_root.join("bun.lock").exists() { return DepsManager::Bun; } if project_root.join("yarn.lock").exists() { return DepsManager::Yarn; } if project_root.join("package-lock.json").exists() { return DepsManager::Npm; } DepsManager::Npm } fn detect_manager_from_package_json(project_root: &Path) -> Option<DepsManager> { let package_json = project_root.join("package.json"); if !package_json.exists() { return None; } let contents = std::fs::read_to_string(package_json).ok()?; let json = serde_json::from_str::<serde_json::Value>(&contents).ok()?; let manager = json.get("packageManager")?.as_str()?; if manager.starts_with("pnpm@") { return Some(DepsManager::Pnpm); } if manager.starts_with("bun@") { return Some(DepsManager::Bun); } if manager.starts_with("yarn@") { return Some(DepsManager::Yarn); } if manager.starts_with("npm@") { return Some(DepsManager::Npm); } None } fn is_workspace(project_root: &Path) -> bool { project_root.join("pnpm-workspace.yaml").exists() } fn project_root() -> Result<PathBuf> { let cwd = std::env::current_dir().context("failed to read current directory")?; if let Some(flow_path) = find_flow_toml(&cwd) { return Ok(flow_path.parent().unwrap_or(&cwd).to_path_buf()); } Ok(cwd) } fn find_flow_toml(start: &PathBuf) -> Option<PathBuf> { let mut current = start.clone(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } #[derive(Debug, Clone)] struct UpdateDetectContext { cwd: PathBuf, search_root: PathBuf, } #[derive(Debug, Clone)] struct UpdateTarget { root: PathBuf, ecosystem: DepsEcosystem, detail: UpdateTargetDetail, } #[derive(Debug, Clone)] enum UpdateTargetDetail { Js { manager: DepsManager, workspace: bool, }, Rust, Go, } #[derive(Debug, Clone)] struct PlannedCommand { program: String, args: Vec<String>, cwd: PathBuf, } #[derive(Debug, Clone)] struct UpdatePlan { target: UpdateTarget, commands: Vec<PlannedCommand>, } trait EcosystemUpdater { fn ecosystem(&self) -> DepsEcosystem; fn detect_target( &self, ctx: &UpdateDetectContext, opts: &UpdateDepsOpts, ) -> Result<Option<UpdateTarget>>; fn build_commands( &self, target: &UpdateTarget, opts: &UpdateDepsOpts, ) -> Result<Vec<PlannedCommand>>; } struct JavaScriptUpdater; struct RustUpdater; struct GoUpdater; impl EcosystemUpdater for JavaScriptUpdater { fn ecosystem(&self) -> DepsEcosystem { DepsEcosystem::Js } fn detect_target( &self, ctx: &UpdateDetectContext, opts: &UpdateDepsOpts, ) -> Result<Option<UpdateTarget>> { let nearest = nearest_ancestor_with_file(&ctx.cwd, &ctx.search_root, "package.json"); let Some(nearest_pkg) = nearest else { return Ok(None); }; let root = find_js_workspace_root(&nearest_pkg, &ctx.search_root).unwrap_or(nearest_pkg.clone()); let manager = opts.manager.unwrap_or_else(|| detect_manager(&root)); let workspace = root != nearest_pkg || is_js_workspace_root(&root); Ok(Some(UpdateTarget { root, ecosystem: DepsEcosystem::Js, detail: UpdateTargetDetail::Js { manager, workspace }, })) } fn build_commands( &self, target: &UpdateTarget, opts: &UpdateDepsOpts, ) -> Result<Vec<PlannedCommand>> { let UpdateTargetDetail::Js { manager, workspace } = target.detail else { bail!("invalid js update target"); }; let mut args = match manager { DepsManager::Pnpm => { let mut args = Vec::new(); if workspace { args.push("-r".to_string()); } args.push("up".to_string()); if opts.latest { args.push("--latest".to_string()); } args } DepsManager::Bun => { let mut args = vec!["update".to_string()]; if opts.latest { args.push("--latest".to_string()); } args } DepsManager::Yarn => vec!["up".to_string()], DepsManager::Npm => vec!["update".to_string()], }; args.extend(opts.args.clone()); Ok(vec![PlannedCommand { program: manager_program(manager).to_string(), args, cwd: target.root.clone(), }]) } } impl EcosystemUpdater for RustUpdater { fn ecosystem(&self) -> DepsEcosystem { DepsEcosystem::Rust } fn detect_target( &self, ctx: &UpdateDetectContext, _opts: &UpdateDepsOpts, ) -> Result<Option<UpdateTarget>> { let root = find_rust_update_root(&ctx.cwd, &ctx.search_root); let Some(root) = root else { return Ok(None); }; Ok(Some(UpdateTarget { root, ecosystem: DepsEcosystem::Rust, detail: UpdateTargetDetail::Rust, })) } fn build_commands( &self, target: &UpdateTarget, opts: &UpdateDepsOpts, ) -> Result<Vec<PlannedCommand>> { let mut args = vec!["update".to_string()]; args.extend(opts.args.clone()); Ok(vec![PlannedCommand { program: "cargo".to_string(), args, cwd: target.root.clone(), }]) } } impl EcosystemUpdater for GoUpdater { fn ecosystem(&self) -> DepsEcosystem { DepsEcosystem::Go } fn detect_target( &self, ctx: &UpdateDetectContext, _opts: &UpdateDepsOpts, ) -> Result<Option<UpdateTarget>> { let root = nearest_ancestor_with_file(&ctx.cwd, &ctx.search_root, "go.mod"); let Some(root) = root else { return Ok(None); }; Ok(Some(UpdateTarget { root, ecosystem: DepsEcosystem::Go, detail: UpdateTargetDetail::Go, })) } fn build_commands( &self, target: &UpdateTarget, opts: &UpdateDepsOpts, ) -> Result<Vec<PlannedCommand>> { let mut get_args = vec!["get".to_string(), "-u".to_string()]; if opts.args.is_empty() { get_args.push("./...".to_string()); } else { get_args.extend(opts.args.clone()); } Ok(vec![ PlannedCommand { program: "go".to_string(), args: get_args, cwd: target.root.clone(), }, PlannedCommand { program: "go".to_string(), args: vec!["mod".to_string(), "tidy".to_string()], cwd: target.root.clone(), }, ]) } } fn build_update_plans(ctx: &UpdateDetectContext, opts: &UpdateDepsOpts) -> Result<Vec<UpdatePlan>> { let updaters: Vec<Box<dyn EcosystemUpdater>> = vec![ Box::new(JavaScriptUpdater), Box::new(RustUpdater), Box::new(GoUpdater), ]; let mut plans = Vec::new(); for updater in updaters { if let Some(requested) = opts.ecosystem && requested != updater.ecosystem() { continue; } if let Some(target) = updater.detect_target(ctx, opts)? { let commands = updater.build_commands(&target, opts)?; plans.push(UpdatePlan { target, commands }); } } Ok(plans) } fn run_update_plans(plans: &[UpdatePlan]) -> Result<()> { for plan in plans { for cmd in &plan.commands { println!( "→ [{}] {}", ecosystem_label(plan.target.ecosystem), display_command(cmd) ); let status = Command::new(&cmd.program) .args(&cmd.args) .current_dir(&cmd.cwd) .status() .with_context(|| format!("failed to run {}", cmd.program))?; if !status.success() { bail!("dependency update command failed: {}", display_command(cmd)); } } } Ok(()) } fn print_update_summary(plans: &[UpdatePlan]) { println!("Detected {} dependency update target(s):", plans.len()); for plan in plans { println!( " [{}] {}", ecosystem_label(plan.target.ecosystem), plan.target.root.display() ); if let UpdateTargetDetail::Js { manager, workspace } = plan.target.detail { println!( " manager: {}{}", manager_program(manager), if workspace { " (workspace)" } else { "" } ); } for cmd in &plan.commands { println!(" $ {}", display_command(cmd)); } } } fn confirm_update_plan(opts: &UpdateDepsOpts, plans: &[UpdatePlan]) -> Result<bool> { let mut lines = Vec::new(); for plan in plans { lines.push(format!( "[{}] {}", ecosystem_label(plan.target.ecosystem), plan.target.root.display() )); for cmd in &plan.commands { lines.push(format!(" $ {}", display_command(cmd))); } } if !opts.no_tui && let Some(answer) = opentui_prompt::confirm("Update Dependencies", &lines, true) { return Ok(answer); } for line in &lines { println!("{}", line); } confirm_default_yes("Proceed with dependency updates? [Y/n]: ") } fn confirm_default_yes(prompt: &str) -> Result<bool> { print!("{}", prompt); io::stdout().flush()?; if io::stdin().is_terminal() { let mut input = String::new(); io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(true); } return Ok(matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes")); } let mut input = String::new(); io::stdin().read_to_string(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(true); } Ok(matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes")) } fn ecosystem_label(ecosystem: DepsEcosystem) -> &'static str { match ecosystem { DepsEcosystem::Js => "js", DepsEcosystem::Rust => "rust", DepsEcosystem::Go => "go", } } fn manager_program(manager: DepsManager) -> &'static str { match manager { DepsManager::Pnpm => "pnpm", DepsManager::Npm => "npm", DepsManager::Yarn => "yarn", DepsManager::Bun => "bun", } } fn display_command(cmd: &PlannedCommand) -> String { if cmd.args.is_empty() { return cmd.program.clone(); } format!("{} {}", cmd.program, cmd.args.join(" ")) } fn update_search_root(cwd: &Path) -> PathBuf { if let Some(flow_path) = find_flow_toml(&cwd.to_path_buf()) { return flow_path.parent().unwrap_or(cwd).to_path_buf(); } filesystem_root(cwd) } fn filesystem_root(path: &Path) -> PathBuf { let mut root = path.to_path_buf(); while let Some(parent) = root.parent() { if parent == root { break; } root = parent.to_path_buf(); } root } fn nearest_ancestor_with_file(start: &Path, boundary: &Path, file: &str) -> Option<PathBuf> { let mut current = start.to_path_buf(); loop { if current.join(file).exists() { return Some(current); } if current == boundary { break; } if !current.pop() { break; } } None } fn find_js_workspace_root(start: &Path, boundary: &Path) -> Option<PathBuf> { let mut current = start.to_path_buf(); loop { if is_js_workspace_root(¤t) { return Some(current); } if current == boundary { break; } if !current.pop() { break; } } None } fn is_js_workspace_root(root: &Path) -> bool { if root.join("pnpm-workspace.yaml").exists() { return true; } package_json_has_workspaces(root) } fn package_json_has_workspaces(root: &Path) -> bool { let package_json = root.join("package.json"); if !package_json.exists() { return false; } let Ok(contents) = std::fs::read_to_string(package_json) else { return false; }; let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else { return false; }; value.get("workspaces").is_some() } fn find_rust_update_root(start: &Path, boundary: &Path) -> Option<PathBuf> { let mut nearest: Option<PathBuf> = None; let mut workspace_root: Option<PathBuf> = None; let mut current = start.to_path_buf(); loop { let cargo_toml = current.join("Cargo.toml"); if cargo_toml.exists() { if nearest.is_none() { nearest = Some(current.clone()); } if workspace_root.is_none() && cargo_toml_has_workspace(&cargo_toml) { workspace_root = Some(current.clone()); } } if current == boundary { break; } if !current.pop() { break; } } workspace_root.or(nearest) } fn cargo_toml_has_workspace(path: &Path) -> bool { let Ok(contents) = std::fs::read_to_string(path) else { return false; }; let Ok(value) = toml::from_str::<toml::Value>(&contents) else { return false; }; value.get("workspace").is_some() } #[derive(Debug)] enum DepPickAction { RepoLink { repo: String }, RepoOpen { owner: String, repo: String }, Project { path: PathBuf }, Message { text: String }, } #[derive(Debug)] struct DepPickEntry { display: String, action: DepPickAction, } #[derive(Debug, Deserialize)] struct RepoManifest { root: Option<String>, repos: Option<Vec<RepoManifestEntry>>, } #[derive(Debug, Deserialize)] struct RepoManifestEntry { owner: String, repo: String, url: Option<String>, } fn pick_dependency(project_root: &Path) -> Result<()> { let manifest = load_repo_manifest(project_root)?; let default_root = manifest .as_ref() .and_then(|m| m.root.clone()) .unwrap_or_else(|| "~/repos".to_string()); let root_path = repos::normalize_root(&default_root)?; let entries = build_pick_entries(project_root, &root_path, manifest.as_ref())?; if entries.is_empty() { println!("No linked repos or dependency metadata found."); return Ok(()); } if which::which("fzf").is_err() { println!("fzf not found on PATH – install it to use fuzzy selection."); for entry in &entries { println!(" {}", entry.display); } return Ok(()); } let Some(entry) = run_deps_fzf(&entries)? else { return Ok(()); }; match &entry.action { DepPickAction::RepoLink { repo } => { link_repo_dependency(project_root, repo, &default_root, false)? } DepPickAction::RepoOpen { owner, repo } => { let repo_ref = repos::RepoRef { owner: owner.clone(), repo: repo.clone(), }; let repo_path = root_path.join(&repo_ref.owner).join(&repo_ref.repo); if !repo_path.exists() { let repo_id = format!("{}/{}", repo_ref.owner, repo_ref.repo); link_repo_dependency(project_root, &repo_id, &default_root, false)?; } open_in_zed(&repo_path)?; } DepPickAction::Project { path } => { println!("Project path: {}", path.display()); println!("Hint: cd {}", path.display()); } DepPickAction::Message { text } => { println!("{}", text); } } Ok(()) } fn run_deps_fzf<'a>(entries: &'a [DepPickEntry]) -> Result<Option<&'a DepPickEntry>> { use std::io::Write; use std::process::Stdio; let mut child = Command::new("fzf") .arg("--prompt") .arg("deps> ") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for entry in entries { writeln!(stdin, "{}", entry.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selection = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let selection = selection.trim(); if selection.is_empty() { return Ok(None); } Ok(entries.iter().find(|entry| entry.display == selection)) } fn build_pick_entries( project_root: &Path, root_path: &Path, manifest: Option<&RepoManifest>, ) -> Result<Vec<DepPickEntry>> { let mut entries = Vec::new(); if let Some(manifest) = manifest { if let Some(repos) = &manifest.repos { for repo in repos { let repo_id = format!("{}/{}", repo.owner, repo.repo); let is_local = root_path.join(&repo.owner).join(&repo.repo).exists(); let _repo_url = repo.url.clone().unwrap_or_else(|| repo_id.clone()); entries.push(DepPickEntry { display: format!( "[linked] {}{}", repo_id, if is_local { " (local)" } else { "" } ), action: DepPickAction::RepoOpen { owner: repo.owner.clone(), repo: repo.repo.clone(), }, }); } } } let scan = scan_project_files(project_root)?; let mut js_deps = BTreeSet::new(); let mut cargo_deps = BTreeSet::new(); let mut project_entries = Vec::new(); for path in scan { if path.file_name().and_then(|n| n.to_str()) == Some("package.json") { if let Ok(info) = parse_package_json(&path) { if let Some(name) = info.name { let dir = path.parent().unwrap_or(&path); if !is_project_root(project_root, dir) { project_entries.push(DepPickEntry { display: format!( "[project] {} ({})", name, path_relative(project_root, dir) ), action: DepPickAction::Project { path: dir.to_path_buf(), }, }); } } for dep in info.deps { js_deps.insert((dep, path.parent().unwrap_or(&path).to_path_buf())); } } } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") { if let Ok(info) = parse_cargo_toml(&path) { if let Some(name) = info.name { let dir = path.parent().unwrap_or(&path); if !is_project_root(project_root, dir) { project_entries.push(DepPickEntry { display: format!( "[project] {} ({})", name, path_relative(project_root, dir) ), action: DepPickAction::Project { path: dir.to_path_buf(), }, }); } } for dep in info.deps { cargo_deps.insert(dep); } } } } entries.extend(project_entries); let cargo_lock = load_cargo_lock(project_root).unwrap_or_default(); for (dep, base_dir) in js_deps { let repo_url = resolve_js_repo(project_root, &base_dir, &dep); if let Some(repo_url) = repo_url { let is_local = local_repo_is_present(root_path, &repo_url); let label = if is_local { "[linked-js]" } else { "[js]" }; let display = display_repo(&repo_url); let action = if is_local { match repos::parse_github_repo(&repo_url) { Ok(repo_ref) => DepPickAction::RepoOpen { owner: repo_ref.owner, repo: repo_ref.repo, }, Err(_) => DepPickAction::RepoLink { repo: repo_url.clone(), }, } } else { DepPickAction::RepoLink { repo: repo_url.clone(), } }; entries.push(DepPickEntry { display: format!("{} {} -> {}", label, dep, display), action, }); } else { entries.push(DepPickEntry { display: format!("[js] {} (no repo found)", dep), action: DepPickAction::Message { text: format!("No repository URL found for {}", dep), }, }); } } for dep in cargo_deps { let repo_url = resolve_cargo_repo(&cargo_lock, &dep); if let Some(repo_url) = repo_url { let is_local = local_repo_is_present(root_path, &repo_url); let label = if is_local { "[linked-crate]" } else { "[crate]" }; let display = display_repo(&repo_url); let action = if is_local { match repos::parse_github_repo(&repo_url) { Ok(repo_ref) => DepPickAction::RepoOpen { owner: repo_ref.owner, repo: repo_ref.repo, }, Err(_) => DepPickAction::RepoLink { repo: repo_url.clone(), }, } } else { DepPickAction::RepoLink { repo: repo_url.clone(), } }; entries.push(DepPickEntry { display: format!("{} {} -> {}", label, dep, display), action, }); } else { entries.push(DepPickEntry { display: format!("[crate] {} (no repo found)", dep), action: DepPickAction::Message { text: format!("No repository URL found for {}", dep), }, }); } } Ok(entries) } fn load_repo_manifest(project_root: &Path) -> Result<Option<RepoManifest>> { let path = project_root.join(".ai").join("repos.toml"); if !path.exists() { return Ok(None); } let contents = std::fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let manifest = toml::from_str::<RepoManifest>(&contents).context("failed to parse .ai/repos.toml")?; Ok(Some(manifest)) } fn scan_project_files(root: &Path) -> Result<Vec<PathBuf>> { let mut paths = Vec::new(); let mut builder = WalkBuilder::new(root); builder.hidden(false); builder.filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); match name.as_ref() { ".git" | ".ai" | "node_modules" | "target" | "dist" | "build" | ".next" | ".turbo" => { return false; } _ => {} } true }); for entry in builder.build() { let entry = match entry { Ok(entry) => entry, Err(_) => continue, }; if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { continue; } let name = entry.file_name().to_string_lossy(); if name == "package.json" || name == "Cargo.toml" { paths.push(entry.into_path()); } } Ok(paths) } struct PackageJsonInfo { name: Option<String>, deps: Vec<String>, } fn parse_package_json(path: &Path) -> Result<PackageJsonInfo> { let contents = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; let value: serde_json::Value = serde_json::from_str(&contents) .with_context(|| format!("failed to parse {}", path.display()))?; let name = value .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let mut deps = BTreeSet::new(); for key in [ "dependencies", "devDependencies", "optionalDependencies", "peerDependencies", ] { if let Some(obj) = value.get(key).and_then(|v| v.as_object()) { for dep in obj.keys() { deps.insert(dep.to_string()); } } } Ok(PackageJsonInfo { name, deps: deps.into_iter().collect(), }) } struct CargoTomlInfo { name: Option<String>, deps: Vec<String>, } fn parse_cargo_toml(path: &Path) -> Result<CargoTomlInfo> { let contents = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; let value: toml::Value = toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))?; let name = value .get("package") .and_then(Value::as_table) .and_then(|pkg| pkg.get("name")) .and_then(Value::as_str) .map(|s| s.to_string()); let mut deps = BTreeSet::new(); for key in ["dependencies", "dev-dependencies", "build-dependencies"] { if let Some(table) = value.get(key).and_then(Value::as_table) { for dep in table.keys() { deps.insert(dep.to_string()); } } } Ok(CargoTomlInfo { name, deps: deps.into_iter().collect(), }) } #[derive(Default)] struct CargoLockIndex { versions: BTreeMap<String, String>, sources: BTreeMap<String, String>, } fn load_cargo_lock(project_root: &Path) -> Result<CargoLockIndex> { let lock_path = project_root.join("Cargo.lock"); if !lock_path.exists() { return Ok(CargoLockIndex::default()); } let contents = std::fs::read_to_string(&lock_path) .with_context(|| format!("failed to read {}", lock_path.display()))?; let value: toml::Value = toml::from_str(&contents) .with_context(|| format!("failed to parse {}", lock_path.display()))?; let mut index = CargoLockIndex::default(); let packages = value .get("package") .and_then(Value::as_array) .cloned() .unwrap_or_default(); for pkg in packages { let table = match pkg.as_table() { Some(table) => table, None => continue, }; let name = match table.get("name").and_then(Value::as_str) { Some(name) => name.to_string(), None => continue, }; if let Some(version) = table.get("version").and_then(Value::as_str) { index .versions .entry(name.clone()) .or_insert_with(|| version.to_string()); } if let Some(source) = table.get("source").and_then(Value::as_str) { if source.starts_with("registry+") { continue; } if let Some(url) = normalize_github_url(source) { index.sources.entry(name).or_insert(url); } } } Ok(index) } fn resolve_js_repo(project_root: &Path, base_dir: &Path, dep: &str) -> Option<String> { let mut candidates = Vec::new(); if base_dir.join("node_modules").exists() { candidates.push(base_dir.join("node_modules")); } if project_root.join("node_modules").exists() { candidates.push(project_root.join("node_modules")); } for base in candidates { let dep_path = join_node_modules(&base, dep).join("package.json"); if dep_path.exists() { if let Ok(contents) = std::fs::read_to_string(&dep_path) { if let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) { if let Some(repo) = extract_repo_url(&value) { if let Some(url) = normalize_github_url(&repo) { return Some(url); } } } } } } None } fn resolve_cargo_repo(index: &CargoLockIndex, dep: &str) -> Option<String> { if let Some(url) = index.sources.get(dep) { return Some(url.clone()); } let version = index.versions.get(dep)?; let cargo_home = cargo_home(); let registry_src = cargo_home.join("registry").join("src"); let entries = std::fs::read_dir(®istry_src).ok()?; for entry in entries.flatten() { let candidate = entry .path() .join(format!("{}-{}", dep, version)) .join("Cargo.toml"); if candidate.exists() { if let Ok(contents) = std::fs::read_to_string(&candidate) { if let Ok(value) = toml::from_str::<toml::Value>(&contents) { if let Some(repo) = value .get("package") .and_then(Value::as_table) .and_then(|pkg| pkg.get("repository")) .and_then(Value::as_str) { if let Some(url) = normalize_github_url(repo) { return Some(url); } } } } } } None } fn cargo_home() -> PathBuf { let raw = std::env::var("CARGO_HOME").unwrap_or_else(|_| "~/.cargo".to_string()); config::expand_path(&raw) } fn join_node_modules(base: &Path, dep: &str) -> PathBuf { if let Some((scope, name)) = dep.split_once('/') { if scope.starts_with('@') { return base.join(scope).join(name); } } base.join(dep) } fn extract_repo_url(value: &serde_json::Value) -> Option<String> { match value.get("repository") { Some(serde_json::Value::String(url)) => Some(url.to_string()), Some(serde_json::Value::Object(map)) => map .get("url") .and_then(|v| v.as_str()) .map(|s| s.to_string()), _ => None, } } fn normalize_github_url(raw: &str) -> Option<String> { let trimmed = raw.trim().trim_start_matches("git+"); let cleaned = trimmed.trim_end_matches('/').trim_end_matches(".git"); if cleaned.contains("crates.io-index") { return None; } if let Ok(repo_ref) = repos::parse_github_repo(cleaned) { return Some(format!( "https://github.com/{}/{}", repo_ref.owner, repo_ref.repo )); } None } fn display_repo(url: &str) -> String { if let Ok(repo_ref) = repos::parse_github_repo(url) { return format!("{}/{}", repo_ref.owner, repo_ref.repo); } url.to_string() } fn local_repo_is_present(root_path: &Path, url: &str) -> bool { if let Ok(repo_ref) = repos::parse_github_repo(url) { if root_path.join(repo_ref.owner).join(repo_ref.repo).exists() { return true; } } false } fn open_in_zed(path: &Path) -> Result<()> { let status = Command::new("open") .args(["-a", "/Applications/Zed.app"]) .arg(path) .status() .context("failed to launch Zed")?; if status.success() { Ok(()) } else { bail!("failed to open {}", path.display()); } } fn path_relative(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) .display() .to_string() } fn is_project_root(root: &Path, candidate: &Path) -> bool { let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let candidate = candidate .canonicalize() .unwrap_or_else(|_| candidate.to_path_buf()); root == candidate } fn link_repo_dependency( project_root: &Path, repo: &str, root: &str, private_origin: bool, ) -> Result<()> { let ai_dir = project_root.join(".ai"); let repos_dir = ai_dir.join("repos"); std::fs::create_dir_all(&repos_dir) .with_context(|| format!("failed to create {}", repos_dir.display()))?; let root_path = repos::normalize_root(root)?; let repo_ref = if looks_like_repo_ref(repo) { repos::parse_github_repo(repo)? } else { resolve_repo_by_name(&root_path, repo)? }; let target_dir = root_path.join(&repo_ref.owner).join(&repo_ref.repo); let mut cloned = false; if !target_dir.exists() { let opts = ReposCloneOpts { url: repo.to_string(), root: root.to_string(), full: false, no_upstream: false, upstream_url: None, }; repos::clone_repo(opts)?; cloned = true; } else { println!("✓ found repo at {}", target_dir.display()); } let origin_url = format!("git@github.com:{}/{}.git", repo_ref.owner, repo_ref.repo); if cloned { if let Err(err) = ensure_upstream(&target_dir, &origin_url) { println!("⚠ upstream setup skipped: {}", err); } } if private_origin { if let Err(err) = maybe_setup_private_origin(&target_dir, &repo_ref, &origin_url) { println!("⚠ private origin setup skipped: {}", err); } } let owner_dir = repos_dir.join(&repo_ref.owner); std::fs::create_dir_all(&owner_dir) .with_context(|| format!("failed to create {}", owner_dir.display()))?; let link_path = owner_dir.join(&repo_ref.repo); if link_path.exists() { println!("✓ link already exists: {}", link_path.display()); } else { #[cfg(unix)] { std::os::unix::fs::symlink(&target_dir, &link_path) .with_context(|| format!("failed to link {}", link_path.display()))?; } #[cfg(windows)] { std::os::windows::fs::symlink_dir(&target_dir, &link_path) .with_context(|| format!("failed to link {}", link_path.display()))?; } println!("✓ linked {}", link_path.display()); } let manifest_path = ai_dir.join("repos.toml"); upsert_repo_manifest(&manifest_path, root, &repo_ref, repo)?; Ok(()) } fn looks_like_repo_ref(input: &str) -> bool { let trimmed = input.trim(); trimmed.contains("github.com/") || trimmed.starts_with("git@github.com:") || trimmed.contains('/') || trimmed.ends_with(".git") } fn resolve_repo_by_name(root: &Path, name: &str) -> Result<repos::RepoRef> { let mut matches = Vec::new(); let root_entries = std::fs::read_dir(root).with_context(|| format!("failed to read {}", root.display()))?; for owner_entry in root_entries.flatten() { if !owner_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } let owner = owner_entry.file_name().to_string_lossy().to_string(); let repo_path = owner_entry.path().join(name); if repo_path.is_dir() { matches.push(repos::RepoRef { owner, repo: name.to_string(), }); } } if matches.is_empty() { bail!( "repo '{}' not found under {}. Use owner/repo or run: f repos clone <url>", name, root.display() ); } if matches.len() > 1 { let options = matches .iter() .map(|repo| format!("{}/{}", repo.owner, repo.repo)) .collect::<Vec<_>>() .join(", "); bail!( "multiple matches for '{}': {}. Use owner/repo.", name, options ); } Ok(matches.remove(0)) } fn upsert_repo_manifest(path: &Path, root: &str, repo: &repos::RepoRef, url: &str) -> Result<()> { let mut doc = if path.exists() { let contents = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; toml::from_str::<Value>(&contents).unwrap_or(Value::Table(Map::new())) } else { Value::Table(Map::new()) }; let table = doc .as_table_mut() .ok_or_else(|| anyhow::anyhow!("invalid repos.toml"))?; table .entry("root".to_string()) .or_insert_with(|| Value::String(root.to_string())); let repos_value = table .entry("repos".to_string()) .or_insert_with(|| Value::Array(Vec::new())); let repos_array = repos_value .as_array_mut() .ok_or_else(|| anyhow::anyhow!("invalid repos list"))?; let exists = repos_array.iter().any(|entry| { entry.get("owner").and_then(Value::as_str) == Some(repo.owner.as_str()) && entry.get("repo").and_then(Value::as_str) == Some(repo.repo.as_str()) }); if !exists { let mut entry = Map::new(); entry.insert("owner".to_string(), Value::String(repo.owner.clone())); entry.insert("repo".to_string(), Value::String(repo.repo.clone())); entry.insert("url".to_string(), Value::String(url.to_string())); repos_array.push(Value::Table(entry)); } let rendered = toml::to_string_pretty(&doc)?; std::fs::write(path, rendered) .with_context(|| format!("failed to write {}", path.display()))?; println!("✓ updated {}", path.display()); Ok(()) } fn maybe_setup_private_origin( repo_dir: &Path, repo_ref: &repos::RepoRef, origin_url: &str, ) -> Result<()> { if !gh_available() { return Ok(()); } if !gh_authenticated()? { println!("gh not authenticated; skipping private origin setup"); println!("Authenticate with: gh auth login"); return Ok(()); } let gh_user = gh_username()?; if gh_user.is_empty() || repo_ref.owner == gh_user { return Ok(()); } if !repo_dir.join(".git").exists() { return Ok(()); } let origin_remote = git_remote_get(repo_dir, "origin")?; if let Some(origin_remote) = origin_remote { if origin_remote.contains(&format!("github.com:{}/", gh_user)) || origin_remote.contains(&format!("github.com/{}/", gh_user)) { return Ok(()); } } let private_repo = format!("{}/{}", gh_user, repo_ref.repo); let private_url = format!("git@github.com:{}.git", private_repo); if !gh_repo_exists(&private_repo)? { println!("Creating private repo: {}", private_repo); let status = Command::new("gh") .args(["repo", "create", &private_repo, "--private"]) .status() .context("failed to create private repo")?; if !status.success() { bail!("failed to create private repo {}", private_repo); } } set_origin_remote(repo_dir, &private_url)?; let upstream_remote = git_remote_get(repo_dir, "upstream")?; if upstream_remote.is_none() { configure_upstream(repo_dir, origin_url)?; } else if upstream_remote.as_deref() != Some(origin_url) { println!( "⚠ upstream already set to {} (expected {})", upstream_remote.unwrap_or_default(), origin_url ); } println!("✓ origin -> {}", private_repo); Ok(()) } fn ensure_upstream(repo_dir: &Path, origin_url: &str) -> Result<()> { if !repo_dir.join(".git").exists() { return Ok(()); } if git_remote_get(repo_dir, "upstream")?.is_some() { return Ok(()); } configure_upstream(repo_dir, origin_url)?; Ok(()) } fn gh_available() -> bool { Command::new("gh").arg("--version").output().is_ok() } fn gh_authenticated() -> Result<bool> { let status = Command::new("gh").args(["auth", "status"]).output()?; Ok(status.status.success()) } fn gh_username() -> Result<String> { let output = Command::new("gh") .args(["api", "user", "-q", ".login"]) .output() .context("failed to get GitHub username")?; Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn gh_repo_exists(full_name: &str) -> Result<bool> { let output = Command::new("gh") .args(["repo", "view", full_name]) .output() .context("failed to check repo")?; Ok(output.status.success()) } fn git_remote_get(repo_dir: &Path, name: &str) -> Result<Option<String>> { let output = Command::new("git") .args(["remote", "get-url", name]) .current_dir(repo_dir) .output(); let output = match output { Ok(output) if output.status.success() => output, _ => return Ok(None), }; Ok(Some( String::from_utf8_lossy(&output.stdout).trim().to_string(), )) } fn set_origin_remote(repo_dir: &Path, url: &str) -> Result<()> { if git_remote_get(repo_dir, "origin")?.is_some() { Command::new("git") .args(["remote", "set-url", "origin", url]) .current_dir(repo_dir) .status() .context("failed to set origin")?; } else { Command::new("git") .args(["remote", "add", "origin", url]) .current_dir(repo_dir) .status() .context("failed to add origin")?; } Ok(()) } fn configure_upstream(repo_dir: &Path, upstream_url: &str) -> Result<()> { let cwd = std::env::current_dir().context("failed to capture current directory")?; std::env::set_current_dir(repo_dir) .with_context(|| format!("failed to enter {}", repo_dir.display()))?; let result = upstream::setup_upstream_with_depth(Some(upstream_url), None, None); if let Err(err) = std::env::set_current_dir(&cwd) { println!("warning: failed to restore working directory: {}", err); } result } #[cfg(test)] mod tests { use super::*; #[test] fn detect_manager_prefers_package_manager_field() { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write( dir.path().join("package.json"), r#"{"name":"x","packageManager":"bun@1.2.3"}"#, ) .expect("write package.json"); std::fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("write lock"); let manager = detect_manager(dir.path()); assert!(matches!(manager, DepsManager::Bun)); } #[test] fn rust_update_root_prefers_workspace_ancestor() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); std::fs::write( root.join("Cargo.toml"), "[workspace]\nmembers=[\"crates/app\"]\n", ) .expect("write root Cargo.toml"); let crate_dir = root.join("crates").join("app"); std::fs::create_dir_all(&crate_dir).expect("mkdir"); std::fs::write( crate_dir.join("Cargo.toml"), "[package]\nname=\"app\"\nversion=\"0.1.0\"\n", ) .expect("write crate Cargo.toml"); let selected = find_rust_update_root(&crate_dir, root).expect("root"); assert_eq!(selected, root); } #[test] fn build_update_plans_detects_js_workspace_root() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().to_path_buf(); std::fs::write( root.join("package.json"), r#"{"name":"mono","workspaces":["packages/*"],"packageManager":"pnpm@10.0.0"}"#, ) .expect("write root package.json"); let app_dir = root.join("packages").join("app"); std::fs::create_dir_all(&app_dir).expect("mkdir"); std::fs::write(app_dir.join("package.json"), r#"{"name":"app"}"#).expect("write app pkg"); let ctx = UpdateDetectContext { cwd: app_dir, search_root: root.clone(), }; let plans = build_update_plans(&ctx, &UpdateDepsOpts::default()).expect("plan"); let js_plan = plans .iter() .find(|p| p.target.ecosystem == DepsEcosystem::Js) .expect("js plan"); assert_eq!(js_plan.target.root, root); } } ================================================ FILE: src/discover.rs ================================================ //! Fast discovery of nested flow.toml files in a project. use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use ignore::WalkBuilder; use serde::{Deserialize, Serialize}; use crate::config::{self, CommandFileConfig, TaskConfig, TaskResolutionConfig}; use crate::fixup; /// A task with its source location information. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoveredTask { /// The task configuration. pub task: TaskConfig, /// Absolute path to the flow.toml containing this task. pub config_path: PathBuf, /// Relative path from the discovery root to the config file's directory. /// Empty string for root-level tasks. pub relative_dir: String, /// Depth from the discovery root (0 = root, 1 = immediate subdirectory, etc.) pub depth: usize, /// Primary scope label used for display and selector prefixes (e.g. "mobile", "root"). pub scope: String, /// Scope aliases accepted during selector matching. pub scope_aliases: Vec<String>, } impl DiscoveredTask { /// Format a display label showing the relative path for nested tasks. pub fn path_label(&self) -> Option<String> { if self.relative_dir.is_empty() { None } else { Some(self.relative_dir.clone()) } } /// Case-insensitive scope match against discovered aliases. pub fn matches_scope(&self, scope: &str) -> bool { let needle = normalize_scope_token(scope); if needle.is_empty() { return false; } self.scope_aliases.iter().any(|alias| alias == &needle) } } /// Result of discovering flow.toml files in a directory tree. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoveryResult { /// All discovered tasks, sorted by depth (root first). pub tasks: Vec<DiscoveredTask>, /// The root config path (if exists). pub root_config: Option<PathBuf>, /// Root task-resolution policy (if configured). pub root_task_resolution: Option<TaskResolutionConfig>, } #[derive(Debug, Clone)] pub(crate) struct DiscoveryArtifacts { pub result: DiscoveryResult, pub watched_paths: Vec<PathBuf>, } #[derive(Debug, Clone, Deserialize)] struct DiscoveryConfigFile { #[serde( default, rename = "name", alias = "project_name", alias = "project-name" )] project_name: Option<String>, #[serde(default)] tasks: Vec<TaskConfig>, #[serde( default, rename = "task_resolution", alias = "task-resolution", alias = "taskResolution" )] task_resolution: Option<TaskResolutionConfig>, #[serde(default, rename = "commands")] command_files: Vec<CommandFileConfig>, } #[derive(Debug, Clone)] struct LoadedDiscoveryConfig { project_name: Option<String>, tasks: Vec<TaskConfig>, task_resolution: Option<TaskResolutionConfig>, } /// Discover all flow.toml files starting from the given root directory. /// Uses the `ignore` crate for fast, gitignore-aware traversal. /// /// Tasks are returned sorted by depth (root-level first, then nested). pub fn discover_tasks(root: &Path) -> Result<DiscoveryResult> { let root = if root.is_absolute() { root.to_path_buf() } else { std::env::current_dir()?.join(root) }; let root = root.canonicalize().unwrap_or(root); discover_tasks_from_root(root) } pub(crate) fn discover_tasks_from_root(root: PathBuf) -> Result<DiscoveryResult> { Ok(discover_tasks_from_root_artifacts(root)?.result) } pub(crate) fn discover_tasks_from_root_artifacts(root: PathBuf) -> Result<DiscoveryArtifacts> { let mut discovered: Vec<DiscoveredTask> = Vec::new(); let mut root_config: Option<PathBuf> = None; let mut root_task_resolution: Option<TaskResolutionConfig> = None; let mut watched_paths = Vec::new(); push_watched_path(&mut watched_paths, &root); // Check if root itself has a flow.toml let root_flow_toml = root.join("flow.toml"); if root_flow_toml.exists() { match load_discovery_config(&root_flow_toml, &mut Vec::new(), &mut watched_paths) { Ok(cfg) => { let (scope, scope_aliases) = infer_scope_metadata("", cfg.project_name.as_deref()); root_config = Some(root_flow_toml.clone()); root_task_resolution = cfg.task_resolution.clone(); for task in &cfg.tasks { discovered.push(DiscoveredTask { task: task.clone(), config_path: root_flow_toml.clone(), relative_dir: String::new(), depth: 0, scope: scope.clone(), scope_aliases: scope_aliases.clone(), }); } } Err(e) => { eprintln!( "Warning: failed to parse {}: {:#}", root_flow_toml.display(), e ); } } } // Walk subdirectories looking for flow.toml files // Use the ignore crate which respects .gitignore and is very fast let walker = WalkBuilder::new(&root) .hidden(true) // skip hidden directories .git_ignore(true) // respect .gitignore .git_global(true) // respect global gitignore .git_exclude(true) // respect .git/info/exclude .max_depth(Some(10)) // reasonable depth limit .filter_entry(|entry| { // Skip common directories that won't have flow.toml we care about if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { let name = entry.file_name().to_string_lossy(); // Skip these directories entirely !matches!( name.as_ref(), "node_modules" | "target" | "dist" | "build" | ".git" | ".hg" | ".svn" | "__pycache__" | ".pytest_cache" | ".mypy_cache" | "venv" | ".venv" | "vendor" | "Pods" | ".cargo" | ".rustup" ) } else { true } }) .build(); for entry in walker.flatten() { let path = entry.path(); if path.is_dir() { push_watched_path(&mut watched_paths, path); } // Skip the root (already handled above) if path == root { continue; } // We're looking for directories that contain flow.toml if !path.is_dir() { continue; } let flow_toml = path.join("flow.toml"); if !flow_toml.exists() { continue; } // Parse the config let cfg = match load_discovery_config(&flow_toml, &mut Vec::new(), &mut watched_paths) { Ok(c) => c, Err(_) => continue, // Skip invalid configs }; // Calculate relative path from root let relative_dir = path .strip_prefix(&root) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); // Calculate depth let depth = relative_dir.matches('/').count() + relative_dir.matches('\\').count() + if relative_dir.is_empty() { 0 } else { 1 }; let (scope, scope_aliases) = infer_scope_metadata(&relative_dir, cfg.project_name.as_deref()); for task in cfg.tasks { discovered.push(DiscoveredTask { task, config_path: flow_toml.clone(), relative_dir: relative_dir.clone(), depth, scope: scope.clone(), scope_aliases: scope_aliases.clone(), }); } } // Sort by depth (root first), then by task name for stability discovered.sort_by(|a, b| { a.depth .cmp(&b.depth) .then_with(|| a.relative_dir.cmp(&b.relative_dir)) .then_with(|| a.task.name.cmp(&b.task.name)) }); Ok(DiscoveryArtifacts { result: DiscoveryResult { tasks: discovered, root_config, root_task_resolution, }, watched_paths, }) } fn normalize_scope_token(raw: &str) -> String { let mut out = String::new(); for ch in raw.trim().chars() { let ch = ch.to_ascii_lowercase(); if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.') { out.push(ch); } else if ch.is_whitespace() { out.push('-'); } } out.trim_matches('-').trim_matches('/').to_string() } fn infer_scope_metadata(relative_dir: &str, project_name: Option<&str>) -> (String, Vec<String>) { let mut aliases: Vec<String> = Vec::new(); let mut push_alias = |raw: &str| { let normalized = normalize_scope_token(raw); if !normalized.is_empty() && !aliases.iter().any(|v| v == &normalized) { aliases.push(normalized); } }; if let Some(name) = project_name { push_alias(name); } else if relative_dir.trim().is_empty() { push_alias("root"); } else { if let Some(leaf) = std::path::Path::new(relative_dir) .file_name() .and_then(|s| s.to_str()) { push_alias(leaf); } push_alias(relative_dir); } let primary = aliases .first() .cloned() .unwrap_or_else(|| "root".to_string()); (primary, aliases) } fn push_watched_path(paths: &mut Vec<PathBuf>, path: &Path) { if !paths.iter().any(|existing| existing == path) { paths.push(path.to_path_buf()); } } fn load_discovery_config( path: &Path, visited: &mut Vec<PathBuf>, watched_paths: &mut Vec<PathBuf>, ) -> Result<LoadedDiscoveryConfig> { let canonical = path.canonicalize()?; if visited.contains(&canonical) { anyhow::bail!( "cycle detected while loading config includes: {}", path.display() ); } visited.push(canonical.clone()); push_watched_path(watched_paths, &canonical); let contents = fs::read_to_string(&canonical)?; let mut cfg = parse_discovery_config(&canonical, &contents)?; let mut project_name = cfg.project_name.take(); let mut tasks = cfg.tasks; let mut task_resolution = cfg.task_resolution.take(); for include in cfg.command_files { let include_path = config::resolve_include_path(&canonical, &include.path); let included = load_discovery_config(&include_path, visited, watched_paths)?; if project_name.is_none() { project_name = included.project_name; } if task_resolution.is_none() { task_resolution = included.task_resolution; } tasks.extend(included.tasks); } visited.pop(); Ok(LoadedDiscoveryConfig { project_name, tasks, task_resolution, }) } fn parse_discovery_config(path: &Path, contents: &str) -> Result<DiscoveryConfigFile> { match toml::from_str(contents) { Ok(cfg) => Ok(cfg), Err(err) => { let fix = fixup::fix_toml_content(contents); if fix.fixes_applied.is_empty() { Err(err).with_context(|| { format!( "failed to parse flow discovery config at {}", path.display() ) }) } else { let fixed = fixup::apply_fixes_to_content(contents, &fix.fixes_applied); fs::write(path, &fixed).with_context(|| { format!( "failed to write auto-fixed discovery config at {}", path.display() ) })?; toml::from_str(&fixed).with_context(|| { format!( "failed to parse flow discovery config at {} (after auto-fix)", path.display() ) }) } } } } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn write_flow_toml(dir: &Path, content: &str) { fs::write(dir.join("flow.toml"), content).unwrap(); } #[test] fn discovers_root_tasks() { let tmp = TempDir::new().unwrap(); write_flow_toml( tmp.path(), r#" [[tasks]] name = "test" command = "echo test" "#, ); let result = discover_tasks(tmp.path()).unwrap(); assert_eq!(result.tasks.len(), 1); assert_eq!(result.tasks[0].task.name, "test"); assert_eq!(result.tasks[0].depth, 0); assert!(result.tasks[0].relative_dir.is_empty()); assert_eq!(result.tasks[0].scope, "root"); } #[test] fn discovers_nested_tasks() { let tmp = TempDir::new().unwrap(); write_flow_toml( tmp.path(), r#" [[tasks]] name = "root-task" command = "echo root" "#, ); let nested = tmp.path().join("packages/api"); fs::create_dir_all(&nested).unwrap(); write_flow_toml( &nested, r#" [[tasks]] name = "api-task" command = "echo api" "#, ); let result = discover_tasks(tmp.path()).unwrap(); assert_eq!(result.tasks.len(), 2); // Root task should come first assert_eq!(result.tasks[0].task.name, "root-task"); assert_eq!(result.tasks[0].depth, 0); // Nested task second assert_eq!(result.tasks[1].task.name, "api-task"); assert!(result.tasks[1].depth > 0); assert!(result.tasks[1].relative_dir.contains("packages")); assert_eq!(result.tasks[1].scope, "api"); } #[test] fn skips_node_modules() { let tmp = TempDir::new().unwrap(); write_flow_toml( tmp.path(), r#" [[tasks]] name = "root" command = "echo root" "#, ); let node_modules = tmp.path().join("node_modules/some-pkg"); fs::create_dir_all(&node_modules).unwrap(); write_flow_toml( &node_modules, r#" [[tasks]] name = "should-skip" command = "echo skip" "#, ); let result = discover_tasks(tmp.path()).unwrap(); assert_eq!(result.tasks.len(), 1); assert_eq!(result.tasks[0].task.name, "root"); } } ================================================ FILE: src/docs.rs ================================================ //! Auto-generated documentation management. //! //! Maintains documentation in `.ai/docs/` that stays in sync with the codebase. use std::fs; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use which::which; use crate::cli::{ DeployCommand, DocsAction, DocsCommand, DocsDeployOpts, DocsHubOpts, DocsNewOpts, }; use crate::{config, deploy}; /// Docs directory relative to project root. const DOCS_DIR: &str = ".ai/docs"; const PROJECT_DOCS_DIR: &str = "docs"; const DEFAULT_DOCS_TEMPLATE_ROOT: &str = "~/new/docs"; const HUB_CONTENT_ROOT: &str = "content/docs"; const DOCS_HUB_FOCUS_FILE: &str = ".flow-focus"; /// Run the docs command. pub fn run(cmd: DocsCommand) -> Result<()> { let project_root = std::env::current_dir()?; let docs_dir = project_root.join(DOCS_DIR); match cmd.action { Some(DocsAction::New(opts)) => create_docs_scaffold(&project_root, opts), Some(DocsAction::Hub(opts)) => run_docs_hub(opts), None => open_project_docs(&project_root), Some(DocsAction::Deploy(opts)) => deploy_docs_hub(&project_root, opts), Some(DocsAction::List) => list_docs(&docs_dir), Some(DocsAction::Status) => show_status(&project_root, &docs_dir), Some(DocsAction::Sync { commits, dry }) => { sync_docs(&project_root, &docs_dir, commits, dry) } Some(DocsAction::Edit { name }) => edit_doc(&docs_dir, &name), } } /// List all documentation files. fn list_docs(docs_dir: &Path) -> Result<()> { if !docs_dir.exists() { println!("No docs directory. Run `f setup` to create .ai/docs/"); return Ok(()); } let entries: Vec<_> = fs::read_dir(docs_dir)? .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) .collect(); if entries.is_empty() { println!("No documentation files in .ai/docs/"); return Ok(()); } println!("Documentation files in .ai/docs/:\n"); for entry in entries { let path = entry.path(); let name = path.file_stem().unwrap_or_default().to_string_lossy(); // Read first line as title let title = fs::read_to_string(&path) .ok() .and_then(|content| { content .lines() .find(|l| l.starts_with("# ")) .map(|l| l.trim_start_matches("# ").to_string()) }) .unwrap_or_default(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let size_str = if size > 1024 { format!("{:.1}KB", size as f64 / 1024.0) } else { format!("{}B", size) }; println!(" {:<15} {:>8} {}", name, size_str, title); } Ok(()) } /// Show documentation status. fn show_status(project_root: &Path, docs_dir: &Path) -> Result<()> { if !docs_dir.exists() { println!("No docs directory. Run `f setup` to create .ai/docs/"); return Ok(()); } // Get recent commits let output = Command::new("git") .args(["log", "--oneline", "-10"]) .current_dir(project_root) .output() .context("failed to run git log")?; let commits = String::from_utf8_lossy(&output.stdout); println!("Recent commits (may need documentation):\n"); for line in commits.lines() { println!(" {}", line); } // Check last sync marker let marker_path = docs_dir.join(".last_sync"); if marker_path.exists() { let last_sync = fs::read_to_string(&marker_path)?; println!("\nLast sync: {}", last_sync.trim()); } else { println!("\nNo sync marker found. Run `f docs sync` to update."); } // List doc files with modification times println!("\nDoc files:"); let entries: Vec<_> = fs::read_dir(docs_dir)? .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) .collect(); for entry in entries { let path = entry.path(); let name = path.file_name().unwrap_or_default().to_string_lossy(); let modified = entry .metadata() .and_then(|m| m.modified()) .map(|t| { let duration = t.elapsed().unwrap_or_default(); format_duration(duration) }) .unwrap_or_else(|_| "unknown".to_string()); println!(" {:<20} modified {}", name, modified); } Ok(()) } /// Sync documentation with recent commits. fn sync_docs(project_root: &Path, docs_dir: &Path, commits: usize, dry: bool) -> Result<()> { if !docs_dir.exists() { bail!("No docs directory. Run `f setup` to create .ai/docs/"); } // Get recent commit messages and diffs let output = Command::new("git") .args(["log", "--oneline", &format!("-{}", commits)]) .current_dir(project_root) .output() .context("failed to run git log")?; let commit_list = String::from_utf8_lossy(&output.stdout); println!("Analyzing {} recent commits...\n", commits); for line in commit_list.lines() { println!(" {}", line); } if dry { println!("\n[Dry run] Would analyze commits and update:"); println!(" - commands.md (if new commands added)"); println!(" - changelog.md (add entries for new features)"); println!(" - architecture.md (if structure changed)"); return Ok(()); } // Update sync marker let marker_path = docs_dir.join(".last_sync"); let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); // Get current HEAD let head = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .current_dir(project_root) .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); fs::write(&marker_path, format!("{} ({})\n", now, head))?; println!("\n✓ Sync marker updated"); println!("\nTo fully sync docs, use an AI assistant to:"); println!(" 1. Review recent commits"); println!(" 2. Update changelog.md with new features"); println!(" 3. Update commands.md if CLI changed"); println!(" 4. Update architecture.md if structure changed"); Ok(()) } /// Open a doc file in the editor. fn edit_doc(docs_dir: &Path, name: &str) -> Result<()> { let doc_path = docs_dir.join(format!("{}.md", name)); if !doc_path.exists() { bail!("Doc file not found: {}.md", name); } let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); Command::new(&editor) .arg(&doc_path) .status() .with_context(|| format!("failed to open {} with {}", doc_path.display(), editor))?; Ok(()) } /// Format a duration as a human-readable string. fn format_duration(duration: std::time::Duration) -> String { let secs = duration.as_secs(); if secs < 60 { format!("{}s ago", secs) } else if secs < 3600 { format!("{}m ago", secs / 60) } else if secs < 86400 { format!("{}h ago", secs / 3600) } else { format!("{}d ago", secs / 86400) } } fn create_docs_scaffold(project_root: &Path, opts: DocsNewOpts) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; let target_root = match opts.path { Some(path) => { let raw = path.to_string_lossy(); let expanded = config::expand_path(&raw); if expanded.is_absolute() { expanded } else { cwd.join(expanded) } } None => project_root.to_path_buf(), }; create_docs_scaffold_at(&target_root, opts.force) } pub fn create_docs_scaffold_at(project_root: &Path, force: bool) -> Result<()> { let docs_dir = project_root.join(PROJECT_DOCS_DIR); if docs_dir.exists() { if docs_dir.is_file() { bail!("docs/ exists but is a file: {}", docs_dir.display()); } if !force { let template_root = config::expand_path(DEFAULT_DOCS_TEMPLATE_ROOT); let template_docs = template_root.join(HUB_CONTENT_ROOT); if !template_docs.exists() { bail!("Docs template not found at {}", template_docs.display()); } merge_docs_scaffold(&docs_dir, &template_docs)?; ensure_index_file(&docs_dir, "Docs")?; println!( "Docs already exists; merged template into {}", docs_dir.display() ); return Ok(()); } fs::remove_dir_all(&docs_dir) .with_context(|| format!("failed to remove {}", docs_dir.display()))?; } let template_root = config::expand_path(DEFAULT_DOCS_TEMPLATE_ROOT); let template_docs = template_root.join(HUB_CONTENT_ROOT); if !template_docs.exists() { bail!("Docs template not found at {}", template_docs.display()); } fs::create_dir_all(&docs_dir) .with_context(|| format!("failed to create {}", docs_dir.display()))?; copy_dir_filtered(&template_docs, &docs_dir, true)?; ensure_index_file(&docs_dir, "Docs")?; println!("Created {}", docs_dir.display()); Ok(()) } fn run_docs_hub(opts: DocsHubOpts) -> Result<()> { let hub_root = config::expand_path(&opts.hub_root); let template_root = config::expand_path(&opts.template_root); ensure_docs_hub(&hub_root, &template_root)?; let code_root = config::expand_path(&opts.code_root); let org_root = config::expand_path(&opts.org_root); let projects = collect_projects(&code_root, &org_root, !opts.no_ai)?; sync_docs_hub_content(&hub_root, &projects)?; if opts.sync_only { println!("Docs hub content synced."); return Ok(()); } ensure_docs_hub_deps(&hub_root)?; run_docs_hub_dev(&hub_root, &opts.host, opts.port, opts.no_open) } fn ensure_docs_hub(hub_root: &Path, template_root: &Path) -> Result<()> { if hub_root.join("package.json").exists() { sync_docs_hub_template_file(hub_root, template_root, "mdx-components.tsx", true)?; sync_docs_hub_template_file(hub_root, template_root, "next.config.mjs", true)?; sync_docs_hub_template_file(hub_root, template_root, "public/favicon.ico", false)?; sync_docs_hub_template_file(hub_root, template_root, "wrangler.toml", false)?; ensure_docs_hub_flow_toml(hub_root, template_root)?; ensure_docs_hub_config(hub_root)?; ensure_docs_hub_layout(hub_root)?; return Ok(()); } if !template_root.exists() { bail!("Docs template root not found: {}", template_root.display()); } fs::create_dir_all(hub_root) .with_context(|| format!("failed to create {}", hub_root.display()))?; copy_template_dir(template_root, hub_root)?; ensure_docs_hub_config(hub_root)?; ensure_docs_hub_layout(hub_root)?; Ok(()) } pub fn ensure_docs_hub_daemon(opts: &DocsHubOpts) -> Result<()> { let focus_root = focus_project_root_from_env(); ensure_docs_hub_daemon_with_focus(opts, focus_root.as_deref()) } fn ensure_docs_hub_daemon_with_focus(opts: &DocsHubOpts, focus_root: Option<&Path>) -> Result<()> { let hub_root = config::expand_path(&opts.hub_root); let template_root = config::expand_path(&opts.template_root); println!( "Docs hub: root={} template={}", hub_root.display(), template_root.display() ); ensure_docs_hub(&hub_root, &template_root)?; let code_root = config::expand_path(&opts.code_root); let org_root = config::expand_path(&opts.org_root); let focus_project = focus_root.and_then(|root| project_docs_for_root(root, &code_root, &org_root, !opts.no_ai)); if let Some(project) = focus_project { println!( "Docs hub: syncing focused project {} ({})", project.name, project.slug ); let expected_path = hub_root.join(HUB_CONTENT_ROOT).join(&project.slug); let focus_match = read_docs_hub_focus_marker(&hub_root) .map(|slug| slug == project.slug) .unwrap_or(false); let hub_running = docs_hub_healthy(&opts.host, opts.port); if !(focus_match && expected_path.exists() && hub_running) { sync_docs_hub_content_focus(&hub_root, &project)?; } else { println!("Docs hub: already running; skipping sync."); } } else { let projects = collect_projects(&code_root, &org_root, !opts.no_ai)?; println!( "Docs hub: syncing {} project(s) from {} and {}", projects.len(), code_root.display(), org_root.display() ); sync_docs_hub_content(&hub_root, &projects)?; } let needs_reset = docs_hub_needs_reset(&hub_root)?; let was_running = docs_hub_healthy(&opts.host, opts.port); if needs_reset { println!("Docs hub: stale index detected; resetting cache."); if let Some(pid) = load_docs_hub_pid()? { terminate_process(pid).ok(); remove_docs_hub_pid().ok(); } kill_docs_hub_by_port(opts.port).ok(); remove_docs_hub_cache(&hub_root).ok(); } if was_running && !needs_reset && focus_root.is_none() { println!("Docs hub: restarting to apply latest docs."); if let Some(pid) = load_docs_hub_pid()? { terminate_process(pid).ok(); remove_docs_hub_pid().ok(); } kill_docs_hub_by_port(opts.port).ok(); remove_docs_hub_cache(&hub_root).ok(); } if !needs_reset && !was_running { if let Some(pid) = load_docs_hub_pid()? { if process_alive(pid)? { println!( "Docs hub: already running at http://{}:{}", opts.host, opts.port ); return Ok(()); } remove_docs_hub_pid().ok(); } } if was_running && !needs_reset { return Ok(()); } ensure_docs_hub_deps(&hub_root)?; start_docs_hub_daemon(&hub_root, &opts.host, opts.port)?; println!( "Docs hub: starting dev server on http://{}:{}", opts.host, opts.port ); wait_for_port(&opts.host, opts.port, std::time::Duration::from_secs(10)); Ok(()) } pub fn stop_docs_hub_daemon() -> Result<()> { if let Some(pid) = load_docs_hub_pid()? { terminate_process(pid).ok(); remove_docs_hub_pid().ok(); } kill_docs_hub_by_port(4410).ok(); Ok(()) } fn ensure_docs_hub_deps(hub_root: &Path) -> Result<()> { let node_modules = hub_root.join("node_modules"); if node_modules.exists() { return Ok(()); } if which("bun").is_ok() { run_command("bun", &["install"], hub_root) } else if which("npm").is_ok() { run_command("npm", &["install"], hub_root) } else { bail!("bun or npm is required to install docs hub dependencies"); } } fn run_docs_hub_dev(hub_root: &Path, host: &str, port: u16, no_open: bool) -> Result<()> { let mut cmd = if which("bun").is_ok() { let port_arg = port.to_string(); let host_arg = host.to_string(); let mut cmd = Command::new("bun"); cmd.args([ "run", "dev", "--", "--port", &port_arg, "--hostname", &host_arg, ]); cmd } else if which("npm").is_ok() { let port_arg = port.to_string(); let host_arg = host.to_string(); let mut cmd = Command::new("npm"); cmd.args([ "run", "dev", "--", "--port", &port_arg, "--hostname", &host_arg, ]); cmd } else { bail!("bun or npm is required to run docs hub dev server"); }; let mut child = cmd .current_dir(hub_root) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .spawn() .context("failed to start docs hub dev server")?; if !no_open { let url = format!("http://{}:{}", host, port); wait_for_port(host, port, std::time::Duration::from_secs(10)); open_in_browser(&url); } let status = child.wait().context("failed to wait on docs hub")?; if !status.success() { bail!("docs hub dev server exited with error"); } Ok(()) } fn start_docs_hub_daemon(hub_root: &Path, host: &str, port: u16) -> Result<()> { let mut cmd = if which("bun").is_ok() { let port_arg = port.to_string(); let host_arg = host.to_string(); let mut cmd = Command::new("bun"); cmd.args([ "run", "dev", "--", "--port", &port_arg, "--hostname", &host_arg, ]); cmd } else if which("npm").is_ok() { let port_arg = port.to_string(); let host_arg = host.to_string(); let mut cmd = Command::new("npm"); cmd.args([ "run", "dev", "--", "--port", &port_arg, "--hostname", &host_arg, ]); cmd } else { bail!("bun or npm is required to run docs hub dev server"); }; let child = cmd .current_dir(hub_root) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .context("failed to start docs hub daemon")?; persist_docs_hub_pid(child.id())?; Ok(()) } fn open_in_browser(url: &str) { let _ = Command::new("open").arg(url).status(); } struct DirGuard { previous: PathBuf, } impl DirGuard { fn new(path: &Path) -> Result<Self> { let previous = std::env::current_dir().context("failed to read current directory")?; std::env::set_current_dir(path) .with_context(|| format!("failed to switch to {}", path.display()))?; Ok(Self { previous }) } } impl Drop for DirGuard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.previous); } } fn run_command(cmd: &str, args: &[&str], cwd: &Path) -> Result<()> { let status = Command::new(cmd) .args(args) .current_dir(cwd) .status() .with_context(|| format!("failed to run {}", cmd))?; if !status.success() { bail!("{} failed", cmd); } Ok(()) } fn attach_pages_domain(hub_root: &Path, project: &str, domain: &str) -> Result<()> { println!("Attaching custom domain {domain} to {project}..."); let mut cmd = if which("bun").is_ok() { let mut cmd = Command::new("bun"); cmd.args(["x", "wrangler", "pages", "domain", "add", project, domain]); cmd } else if which("npx").is_ok() { let mut cmd = Command::new("npx"); cmd.args(["wrangler", "pages", "domain", "add", project, domain]); cmd } else if which("npm").is_ok() { let mut cmd = Command::new("npm"); cmd.args([ "exec", "wrangler", "--", "pages", "domain", "add", project, domain, ]); cmd } else { bail!("bun, npx, or npm is required to run wrangler"); }; let status = cmd .current_dir(hub_root) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .status() .context("failed to run wrangler pages domain add")?; if !status.success() { bail!("wrangler pages domain add failed"); } Ok(()) } fn prompt_line(message: &str, default: Option<&str>) -> Result<String> { if let Some(default) = default { print!("{message} [{default}]: "); } else { print!("{message}: "); } io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(default.unwrap_or("").to_string()); } Ok(trimmed.to_string()) } fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> { let prompt = if default_yes { "[Y/n]" } else { "[y/N]" }; print!("{message} {prompt}: "); io::stdout().flush()?; if io::stdin().is_terminal() { let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_yes); } return Ok(answer == "y" || answer == "yes"); } let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_yes); } Ok(answer == "y" || answer == "yes") } fn wait_for_port(host: &str, port: u16, timeout: std::time::Duration) { let start = std::time::Instant::now(); while start.elapsed() < timeout { if std::net::TcpStream::connect((host, port)).is_ok() { return; } std::thread::sleep(std::time::Duration::from_millis(200)); } } fn docs_hub_healthy(host: &str, port: u16) -> bool { std::net::TcpStream::connect((host, port)).is_ok() } fn docs_hub_pid_path() -> PathBuf { config::global_state_dir().join("docs-hub.pid") } fn load_docs_hub_pid() -> Result<Option<u32>> { let path = docs_hub_pid_path(); if !path.exists() { return Ok(None); } let contents = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let pid: u32 = contents.trim().parse().ok().unwrap_or(0); if pid == 0 { Ok(None) } else { Ok(Some(pid)) } } fn persist_docs_hub_pid(pid: u32) -> Result<()> { let path = docs_hub_pid_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } fs::write(&path, pid.to_string()) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn remove_docs_hub_pid() -> Result<()> { let path = docs_hub_pid_path(); if path.exists() { fs::remove_file(path).ok(); } Ok(()) } fn process_alive(pid: u32) -> Result<bool> { #[cfg(unix)] { let status = Command::new("kill").arg("-0").arg(pid.to_string()).status(); return Ok(status.map(|s| s.success()).unwrap_or(false)); } #[cfg(windows)] { let output = Command::new("tasklist") .output() .context("failed to invoke tasklist")?; if !output.status.success() { return Ok(false); } let needle = pid.to_string(); let body = String::from_utf8_lossy(&output.stdout); Ok(body.lines().any(|line| line.contains(&needle))) } } fn terminate_process(pid: u32) -> Result<()> { #[cfg(unix)] { Command::new("kill") .arg(pid.to_string()) .status() .context("failed to invoke kill")?; return Ok(()); } #[cfg(windows)] { Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) .status() .context("failed to invoke taskkill")?; Ok(()) } } fn open_project_docs(project_root: &Path) -> Result<()> { let code_root = config::expand_path("~/code"); let org_root = config::expand_path("~/org"); let Some(project) = project_docs_for_root(project_root, &code_root, &org_root, false) else { bail!("Unable to resolve docs for {}", project_root.display()); }; let hub_opts = DocsHubOpts { host: "127.0.0.1".to_string(), port: 4410, hub_root: "~/.config/flow/docs-hub".to_string(), template_root: DEFAULT_DOCS_TEMPLATE_ROOT.to_string(), code_root: "~/code".to_string(), org_root: "~/org".to_string(), no_ai: true, no_open: true, sync_only: false, }; ensure_docs_hub_daemon_with_focus(&hub_opts, Some(project_root))?; if !(project_root.starts_with(&code_root) || project_root.starts_with(&org_root)) { println!( "Docs hub only indexes ~/code and ~/org; {} may not be available.", project_root.display() ); } let url = format!( "http://{}:{}/{}", hub_opts.host, hub_opts.port, project.slug ); println!( "Docs hub open: project={} slug={}", project_root.display(), project.slug ); open_in_browser(&url); println!("Opened {url}"); Ok(()) } fn deploy_docs_hub(project_root: &Path, opts: DocsDeployOpts) -> Result<()> { let code_root = config::expand_path("~/code"); let org_root = config::expand_path("~/org"); let Some(project) = project_docs_for_root(project_root, &code_root, &org_root, false) else { bail!("Unable to resolve docs for {}", project_root.display()); }; let default_project = if !project.slug.is_empty() { project.slug.clone() } else { slugify_token(&project.name) }; let project_name = opts.project.as_deref().unwrap_or(&default_project).trim(); let project_name = if project_name.is_empty() { default_project.clone() } else { slugify_token(project_name) }; let domain = if let Some(domain) = opts.domain.as_deref() { let trimmed = domain.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } else if opts.yes { None } else { let input = prompt_line("Custom domain (leave blank to skip)", None)?; let trimmed = input.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }; if !opts.yes { println!("Docs deploy:"); println!(" Project: {}", project_name); println!(" Source: {}", project_root.display()); if let Some(domain) = &domain { println!(" Domain: {}", domain); } else { println!(" Domain: (none)"); } if !prompt_yes_no("Proceed with deploy?", true)? { println!("Canceled."); return Ok(()); } } let hub_root = config::expand_path("~/.config/flow/docs-hub"); let template_root = config::expand_path(DEFAULT_DOCS_TEMPLATE_ROOT); ensure_docs_hub(&hub_root, &template_root)?; println!( "Docs hub: syncing focused project {} ({})", project.name, project.slug ); sync_docs_hub_content_focus(&hub_root, &project)?; ensure_docs_hub_deps(&hub_root)?; let _guard = DirGuard::new(&hub_root)?; unsafe { std::env::set_var("FLOW_DOCS_PROJECT", &project_name); } deploy::run(DeployCommand { action: None })?; if let Some(domain) = &domain { attach_pages_domain(&hub_root, &project_name, domain)?; } println!("Docs deploy complete."); Ok(()) } #[derive(Debug, Clone)] struct ProjectDocs { name: String, slug: String, slug_base: String, slug_path: String, root: PathBuf, docs_dir: Option<PathBuf>, ai_docs_dir: Option<PathBuf>, ai_web_dir: Option<PathBuf>, } fn collect_projects( code_root: &Path, org_root: &Path, include_ai: bool, ) -> Result<Vec<ProjectDocs>> { let mut projects = Vec::new(); collect_projects_from_root(&mut projects, code_root, "code", include_ai)?; collect_projects_from_root(&mut projects, org_root, "org", include_ai)?; resolve_project_slugs(&mut projects); projects.sort_by(|a, b| a.slug.cmp(&b.slug)); Ok(projects) } fn collect_projects_from_root( projects: &mut Vec<ProjectDocs>, root: &Path, scope: &str, include_ai: bool, ) -> Result<()> { if !root.exists() { return Ok(()); } let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { let name = dir.file_name().and_then(|s| s.to_str()).unwrap_or(""); if should_skip_dir(name) { continue; } let docs_dir = dir.join(PROJECT_DOCS_DIR); let ai_docs_dir = dir.join(".ai").join("docs"); let ai_web_dir = dir.join(".ai").join("web"); let has_docs = docs_dir.is_dir(); let has_ai = include_ai && ai_docs_dir.is_dir(); let has_web = include_ai && ai_web_dir.is_dir(); if has_docs || has_ai || has_web { let path_slug = slug_for_path(&dir, root, Some(scope)); let name = project_name_from_flow_toml(&dir).unwrap_or_else(|| { dir.file_name() .and_then(|s| s.to_str()) .unwrap_or(&path_slug) .to_string() }); let slug_base = slugify_project_name(&name, &path_slug); projects.push(ProjectDocs { name, slug: slug_base.clone(), slug_base, slug_path: path_slug, root: dir.clone(), docs_dir: if has_docs { Some(docs_dir) } else { None }, ai_docs_dir: if has_ai { Some(ai_docs_dir) } else { None }, ai_web_dir: if has_web { Some(ai_web_dir) } else { None }, }); continue; } let entries = match fs::read_dir(&dir) { Ok(entries) => entries, Err(_) => continue, }; for entry in entries.flatten() { let path = entry.path(); let file_type = match entry.file_type() { Ok(ft) => ft, Err(_) => continue, }; if !file_type.is_dir() { continue; } let child_name = entry.file_name().to_string_lossy().to_string(); if should_skip_dir(&child_name) { continue; } stack.push(path); } } Ok(()) } fn slug_for_path(path: &Path, root: &Path, prefix: Option<&str>) -> String { let relative = path.strip_prefix(root).unwrap_or(path); let mut slug = relative.to_string_lossy().replace('\\', "/"); slug = slug.trim().trim_start_matches('/').to_string(); if let Some(prefix) = prefix { if slug.is_empty() { return prefix.to_string(); } return format!("{prefix}/{slug}"); } if slug.is_empty() { "root".to_string() } else { slug } } fn project_name_from_flow_toml(project_root: &Path) -> Option<String> { let flow_path = project_root.join("flow.toml"); if !flow_path.exists() { return None; } let cfg = config::load(&flow_path).ok()?; cfg.project_name } fn slugify_project_name(name: &str, fallback: &str) -> String { let slug = slugify_token(name); if slug.is_empty() { slugify_token(fallback) } else { slug } } fn slugify_token(input: &str) -> String { let mut out = String::new(); let mut last_dash = false; for ch in input.chars() { let ch = ch.to_ascii_lowercase(); if ch.is_ascii_alphanumeric() { out.push(ch); last_dash = false; } else if matches!(ch, '-' | '_' | ' ' | '.' | '/' | '\\') { if !last_dash { out.push('-'); last_dash = true; } } } let trimmed = out.trim_matches('-').to_string(); trimmed } fn resolve_project_slugs(projects: &mut [ProjectDocs]) { let mut counts = std::collections::HashMap::new(); for project in projects.iter() { *counts.entry(project.slug_base.clone()).or_insert(0usize) += 1; } let mut used = std::collections::HashSet::new(); for project in projects.iter_mut() { let mut slug = if project.slug_base.is_empty() { project.slug_path.clone() } else if counts.get(&project.slug_base).copied().unwrap_or(0) > 1 { project.slug_path.clone() } else { project.slug_base.clone() }; if slug.is_empty() { slug = project.slug_path.clone(); } let mut candidate = slug.clone(); let mut counter = 2usize; while used.contains(&candidate) { candidate = format!("{}-{}", slug, counter); counter += 1; } used.insert(candidate.clone()); project.slug = candidate; } } fn project_slug_candidates( project_root: &Path, code_root: &Path, org_root: &Path, ) -> (String, String) { let scope = if project_root.starts_with(org_root) { "org" } else if project_root.starts_with(code_root) { "code" } else { "project" }; let path_slug = if scope == "project" { project_root .file_name() .and_then(|s| s.to_str()) .unwrap_or("project") .to_string() } else { slug_for_path( project_root, if scope == "org" { org_root } else { code_root }, Some(scope), ) }; let name = project_name_from_flow_toml(project_root).unwrap_or_else(|| { project_root .file_name() .and_then(|s| s.to_str()) .unwrap_or(&path_slug) .to_string() }); let slug_base = slugify_project_name(&name, &path_slug); (slug_base, path_slug) } fn focus_project_root_from_env() -> Option<PathBuf> { let raw = std::env::var("FLOW_DOCS_FOCUS").ok()?; let value = raw.trim(); if value.is_empty() { return None; } let lower = value.to_ascii_lowercase(); let root = if matches!(lower.as_str(), "1" | "true" | "yes") { resolve_project_root_from_cwd() } else { let expanded = config::expand_path(value); if expanded.is_file() && expanded.file_name().and_then(|s| s.to_str()) == Some("flow.toml") { expanded.parent().map(|p| p.to_path_buf()) } else if expanded.is_dir() { Some(expanded) } else { None } }?; Some(root) } fn resolve_project_root_from_cwd() -> Option<PathBuf> { let cwd = std::env::current_dir().ok()?; if cwd.join("flow.toml").exists() { return Some(cwd); } let flow_path = find_flow_toml(&cwd)?; flow_path.parent().map(|p| p.to_path_buf()) } fn find_flow_toml(start: &Path) -> Option<PathBuf> { let mut current = start.to_path_buf(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } fn project_docs_for_root( project_root: &Path, code_root: &Path, org_root: &Path, include_ai: bool, ) -> Option<ProjectDocs> { if !project_root.exists() { return None; } let docs_dir = project_root.join(PROJECT_DOCS_DIR); let ai_docs_dir = project_root.join(".ai").join("docs"); let ai_web_dir = project_root.join(".ai").join("web"); let has_docs = docs_dir.is_dir(); let has_ai = include_ai && ai_docs_dir.is_dir(); let has_web = include_ai && ai_web_dir.is_dir(); let name = project_name_from_flow_toml(project_root).unwrap_or_else(|| { project_root .file_name() .and_then(|s| s.to_str()) .unwrap_or("project") .to_string() }); let (slug_base, slug_path) = project_slug_candidates(project_root, code_root, org_root); let mut project = ProjectDocs { name, slug: slug_base.clone(), slug_base, slug_path, root: project_root.to_path_buf(), docs_dir: if has_docs { Some(docs_dir) } else { None }, ai_docs_dir: if has_ai { Some(ai_docs_dir) } else { None }, ai_web_dir: if has_web { Some(ai_web_dir) } else { None }, }; if project.slug.is_empty() { project.slug = project.slug_path.clone(); } let mut projects = vec![project]; resolve_project_slugs(&mut projects); projects.into_iter().next() } fn should_skip_dir(name: &str) -> bool { if name.starts_with('.') { return true; } matches!( name, "node_modules" | "target" | "dist" | "build" | ".git" | ".hg" | ".svn" | "__pycache__" | ".pytest_cache" | ".mypy_cache" | "venv" | ".venv" | "vendor" | "Pods" | ".cargo" | ".rustup" | ".next" | ".turbo" | ".cache" ) } fn sync_docs_hub_content(hub_root: &Path, projects: &[ProjectDocs]) -> Result<()> { let content_root = hub_root.join(HUB_CONTENT_ROOT); let projects_root = content_root.clone(); clear_docs_hub_focus_marker(hub_root).ok(); if content_root.exists() { fs::remove_dir_all(&content_root) .with_context(|| format!("failed to remove {}", content_root.display()))?; } fs::create_dir_all(&projects_root) .with_context(|| format!("failed to create {}", projects_root.display()))?; println!( "Docs hub: writing {} project(s) to {}", projects.len(), projects_root.display() ); for project in projects { let project_root = projects_root.join(&project.slug); fs::create_dir_all(&project_root) .with_context(|| format!("failed to create {}", project_root.display()))?; if let Some(docs_dir) = &project.docs_dir { copy_docs_dir_with_frontmatter(docs_dir, &project_root, true)?; } if let Some(ai_docs_dir) = &project.ai_docs_dir { copy_docs_dir_with_frontmatter(ai_docs_dir, &project_root, false)?; } if let Some(ai_web_dir) = &project.ai_web_dir { copy_docs_dir_with_frontmatter(ai_web_dir, &project_root, false)?; } let index_path = project_root.join("index.mdx"); if let Some(content) = project_readme_content(&project.root, &project.name)? { let index_md = project_root.join("index.md"); if index_md.exists() { fs::remove_file(&index_md).ok(); } fs::write(&index_path, content) .with_context(|| format!("failed to write {}", index_path.display()))?; } else if !index_path.exists() { let mut lines = Vec::new(); lines.push("---".to_string()); lines.push(format!("title: {}", quote_yaml_string(&project.name))); lines.push("---".to_string()); lines.push(String::new()); fs::write(&index_path, lines.join("\n")) .with_context(|| format!("failed to write {}", index_path.display()))?; } } let root_index = content_root.join("index.mdx"); fs::write(&root_index, render_root_index(projects)) .with_context(|| format!("failed to write {}", root_index.display()))?; Ok(()) } fn sync_docs_hub_content_focus(hub_root: &Path, project: &ProjectDocs) -> Result<()> { let content_root = hub_root.join(HUB_CONTENT_ROOT); let projects_root = content_root.clone(); if content_root.exists() { fs::remove_dir_all(&content_root) .with_context(|| format!("failed to remove {}", content_root.display()))?; } fs::create_dir_all(&projects_root) .with_context(|| format!("failed to create {}", projects_root.display()))?; let project_root = projects_root.join(&project.slug); fs::create_dir_all(&project_root) .with_context(|| format!("failed to create {}", project_root.display()))?; if let Some(docs_dir) = &project.docs_dir { copy_docs_dir_with_frontmatter(docs_dir, &project_root, true)?; } if let Some(ai_docs_dir) = &project.ai_docs_dir { copy_docs_dir_with_frontmatter(ai_docs_dir, &project_root, false)?; } if let Some(ai_web_dir) = &project.ai_web_dir { copy_docs_dir_with_frontmatter(ai_web_dir, &project_root, false)?; } let index_path = project_root.join("index.mdx"); if let Some(content) = project_readme_content(&project.root, &project.name)? { let index_md = project_root.join("index.md"); if index_md.exists() { fs::remove_file(&index_md).ok(); } fs::write(&index_path, content) .with_context(|| format!("failed to write {}", index_path.display()))?; } else if !index_path.exists() { let mut lines = Vec::new(); lines.push("---".to_string()); lines.push(format!("title: {}", quote_yaml_string(&project.name))); lines.push("---".to_string()); lines.push(String::new()); fs::write(&index_path, lines.join("\n")) .with_context(|| format!("failed to write {}", index_path.display()))?; } let root_index = content_root.join("index.mdx"); fs::write(&root_index, render_root_index(&[project.clone()])) .with_context(|| format!("failed to write {}", root_index.display()))?; write_docs_hub_focus_marker(hub_root, &project.slug)?; Ok(()) } fn render_root_index(projects: &[ProjectDocs]) -> String { let mut lines = Vec::new(); lines.push("---".to_string()); lines.push("title: Docs".to_string()); lines.push("---".to_string()); lines.push(String::new()); lines.push("# Docs Hub".to_string()); lines.push(String::new()); if projects.is_empty() { lines.push("No docs found yet.".to_string()); lines.push(String::new()); lines .push("Add `docs/` or `.ai/docs` to a project and run `f docs hub` again.".to_string()); lines.push(String::new()); return lines.join("\n"); } lines.push("Projects:".to_string()); lines.push(String::new()); for project in projects { lines.push(format!("- [{}](./{})", project.name, project.slug)); } lines.push(String::new()); lines.join("\n") } fn read_docs_hub_focus_marker(hub_root: &Path) -> Option<String> { let path = hub_root.join(DOCS_HUB_FOCUS_FILE); let value = fs::read_to_string(path).ok()?; let trimmed = value.trim(); if trimmed.is_empty() { return None; } Some(trimmed.to_string()) } fn write_docs_hub_focus_marker(hub_root: &Path, slug: &str) -> Result<()> { let path = hub_root.join(DOCS_HUB_FOCUS_FILE); fs::write(&path, slug).with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn clear_docs_hub_focus_marker(hub_root: &Path) -> Result<()> { let path = hub_root.join(DOCS_HUB_FOCUS_FILE); if path.exists() { fs::remove_file(&path).ok(); } Ok(()) } fn project_readme_content(project_root: &Path, title: &str) -> Result<Option<String>> { let readme_path = find_project_readme(project_root); let Some(readme_path) = readme_path else { return Ok(None); }; let content = fs::read_to_string(&readme_path) .with_context(|| format!("failed to read {}", readme_path.display()))?; let sanitized = sanitize_markdown_content(&content); let stripped = strip_frontmatter(&sanitized); let updated = ensure_frontmatter_title(&stripped, title); Ok(Some(updated)) } fn find_project_readme(project_root: &Path) -> Option<PathBuf> { let candidates = ["README.mdx", "README.md", "readme.mdx", "readme.md"]; for candidate in candidates { let path = project_root.join(candidate); if path.exists() { return Some(path); } } None } fn ensure_index_file(dir: &Path, title: &str) -> Result<()> { let index_md = dir.join("index.md"); let index_mdx = dir.join("index.mdx"); if index_md.exists() || index_mdx.exists() { return Ok(()); } let content = format!("---\ntitle: {}\n---\n", title); fs::write(&index_mdx, content) .with_context(|| format!("failed to write {}", index_mdx.display()))?; Ok(()) } fn copy_dir_filtered(from: &Path, to: &Path, allow_assets: bool) -> Result<()> { fs::create_dir_all(to).with_context(|| format!("failed to create {}", to.display()))?; for entry in fs::read_dir(from).with_context(|| format!("failed to read {}", from.display()))? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; let name = entry.file_name().to_string_lossy().to_string(); if should_skip_dir(&name) { continue; } let dest = to.join(entry.file_name()); if file_type.is_dir() { copy_dir_filtered(&path, &dest, allow_assets)?; } else if file_type.is_file() { if should_copy_doc_file(&path, allow_assets) { fs::copy(&path, &dest) .with_context(|| format!("failed to copy {}", path.display()))?; } } } Ok(()) } fn should_copy_doc_file(path: &Path, allow_assets: bool) -> bool { match path.extension().and_then(|ext| ext.to_str()) { Some("md") | Some("mdx") => true, _ => allow_assets, } } fn merge_docs_scaffold(from: &Path, to: &Path) -> Result<()> { copy_dir_filtered_missing(from, to, true) } fn copy_dir_filtered_missing(from: &Path, to: &Path, allow_assets: bool) -> Result<()> { fs::create_dir_all(to).with_context(|| format!("failed to create {}", to.display()))?; for entry in fs::read_dir(from).with_context(|| format!("failed to read {}", from.display()))? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; let name = entry.file_name().to_string_lossy().to_string(); if should_skip_dir(&name) { continue; } let dest = to.join(entry.file_name()); if file_type.is_dir() { copy_dir_filtered_missing(&path, &dest, allow_assets)?; } else if file_type.is_file() { if dest.exists() { continue; } if should_copy_doc_file(&path, allow_assets) { fs::copy(&path, &dest) .with_context(|| format!("failed to copy {}", path.display()))?; } } } Ok(()) } fn copy_docs_dir_with_frontmatter(from: &Path, to: &Path, overwrite: bool) -> Result<()> { fs::create_dir_all(to).with_context(|| format!("failed to create {}", to.display()))?; for entry in fs::read_dir(from).with_context(|| format!("failed to read {}", from.display()))? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; let name = entry.file_name().to_string_lossy().to_string(); if should_skip_dir(&name) { continue; } let dest = to.join(entry.file_name()); if file_type.is_dir() { copy_docs_dir_with_frontmatter(&path, &dest, overwrite)?; } else if file_type.is_file() { if !overwrite && dest.exists() { continue; } let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); if ext == "toml" { let dest = dest.with_extension("mdx"); if !overwrite && dest.exists() { continue; } let content = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let body = format!("```toml\n{}\n```", content.trim_end_matches('\n')); let title = title_from_filename(&path); let updated = ensure_frontmatter_title(&body, &title); fs::write(&dest, updated.as_bytes()) .with_context(|| format!("failed to write {}", dest.display()))?; continue; } if !matches!(ext, "md" | "mdx") { continue; } let content = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let sanitized = sanitize_markdown_content(&content); let stripped = strip_frontmatter(&sanitized); let title = derive_title(&stripped, &path); let updated = ensure_frontmatter_title(&stripped, &title); fs::write(&dest, updated.as_bytes()) .with_context(|| format!("failed to write {}", dest.display()))?; } } Ok(()) } fn derive_title(content: &str, path: &Path) -> String { if let Some(title) = extract_title_from_frontmatter(content) { return sanitize_title(&title, path); } if let Some(title) = first_heading(content) { return sanitize_title(&title, path); } title_from_filename(path) } fn extract_title_from_frontmatter(content: &str) -> Option<String> { let Some((frontmatter, _)) = split_frontmatter(content) else { return None; }; for line in frontmatter.lines() { let trimmed = line.trim(); if let Some(value) = trimmed.strip_prefix("title:") { let title = value.trim().trim_matches('"').trim_matches('\''); if !title.is_empty() { return Some(title.to_string()); } } } None } fn first_heading(content: &str) -> Option<String> { let rest = split_frontmatter(content) .map(|(_, rest)| rest) .unwrap_or_else(|| content.to_string()); for line in rest.lines() { let trimmed = line.trim_start(); if let Some(title) = trimmed.strip_prefix("# ") { let title = title.trim(); if !title.is_empty() { return Some(title.to_string()); } } } None } fn title_from_filename(path: &Path) -> String { let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Doc"); let mut title = stem.replace('-', " ").replace('_', " "); if let Some(first) = title.get(0..1) { title.replace_range(0..1, &first.to_uppercase()); } title } fn strip_leading_heading(content: &str, title: &str) -> String { let mut lines: Vec<&str> = content.lines().collect(); let mut idx = 0usize; while idx < lines.len() && lines[idx].trim().is_empty() { idx += 1; } if idx < lines.len() { if let Some(heading) = lines[idx].trim().strip_prefix("# ") { if normalize_title(heading) == normalize_title(title) { lines.remove(idx); while idx < lines.len() && lines[idx].trim().is_empty() { lines.remove(idx); } } } } let mut out = lines.join("\n"); if content.ends_with('\n') { out.push('\n'); } out } fn normalize_title(value: &str) -> String { value.trim().to_ascii_lowercase() } fn ensure_frontmatter_title(content: &str, title: &str) -> String { let ends_with_newline = content.ends_with('\n'); let raw_title = title; let title = quote_yaml_string(title); if let Some((frontmatter, rest)) = split_frontmatter(content) { let rest = strip_leading_heading(&rest, raw_title); let cleaned = frontmatter .lines() .filter(|line| !line.trim_start().starts_with("title:")) .collect::<Vec<_>>() .join("\n"); let mut updated = String::new(); updated.push_str("---\n"); updated.push_str(&format!("title: {}\n", title)); if !cleaned.trim().is_empty() { updated.push_str(&cleaned); if !cleaned.ends_with('\n') { updated.push('\n'); } } updated.push_str("---\n"); if !rest.is_empty() { updated.push_str(rest.trim_start_matches('\n')); if ends_with_newline && !updated.ends_with('\n') { updated.push('\n'); } } return updated; } let mut updated = String::new(); updated.push_str("---\n"); updated.push_str(&format!("title: {}\n", title)); updated.push_str("---\n"); if !content.is_empty() { let rest = strip_leading_heading(content, raw_title); updated.push_str(rest.trim_start_matches('\n')); if ends_with_newline && !updated.ends_with('\n') { updated.push('\n'); } } updated } fn split_frontmatter(content: &str) -> Option<(String, String)> { let mut lines = content.lines(); let first = lines.next()?; if first.trim() != "---" { return None; } let mut frontmatter_lines = Vec::new(); let mut in_frontmatter = true; let mut rest_lines = Vec::new(); for line in lines { if in_frontmatter && line.trim() == "---" { in_frontmatter = false; continue; } if in_frontmatter { frontmatter_lines.push(line); } else { rest_lines.push(line); } } if in_frontmatter { return None; } let frontmatter = frontmatter_lines.join("\n"); let rest = rest_lines.join("\n"); Some((frontmatter, rest)) } fn strip_frontmatter(content: &str) -> String { if let Some((_, rest)) = split_frontmatter(content) { let mut out = rest; if content.ends_with('\n') && !out.ends_with('\n') { out.push('\n'); } return out; } content.to_string() } fn sanitize_title(title: &str, path: &Path) -> String { let mut out = String::new(); let mut chars = title.chars().peekable(); while let Some(ch) = chars.next() { if ch == '`' { continue; } if ch == '[' { let mut text = String::new(); while let Some(c) = chars.next() { if c == ']' { break; } text.push(c); } if matches!(chars.peek(), Some('(')) { chars.next(); let mut depth = 1; while let Some(c) = chars.next() { if c == '(' { depth += 1; } else if c == ')' { depth -= 1; if depth == 0 { break; } } } } out.push_str(&text); continue; } out.push(ch); } let trimmed = out.trim(); if trimmed.is_empty() { return title_from_filename(path); } trimmed.to_string() } fn quote_yaml_string(value: &str) -> String { let mut out = String::new(); out.push('"'); for ch in value.chars() { match ch { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), _ => out.push(ch), } } out.push('"'); out } fn is_mdx_declaration_line(trimmed: &str) -> bool { if trimmed.starts_with("import ") { return true; } if trimmed.starts_with("export ") { let rest = trimmed.trim_start_matches("export ").trim_start(); return rest.starts_with("const ") || rest.starts_with("default") || rest.starts_with("function ") || rest.starts_with("type ") || rest.starts_with("interface ") || rest.starts_with("{"); } false } fn sanitize_markdown_content(content: &str) -> String { let mut out = Vec::new(); let mut in_code = false; let mut fence = String::new(); let mut in_frontmatter = false; let mut frontmatter_checked = false; for line in content.lines() { let trimmed = line.trim_start(); if !frontmatter_checked { frontmatter_checked = true; if trimmed == "---" { in_frontmatter = true; out.push(line.to_string()); continue; } } if in_frontmatter { out.push(line.to_string()); if trimmed == "---" { in_frontmatter = false; } continue; } if !in_code && is_mdx_declaration_line(trimmed) { continue; } if trimmed.starts_with("```") || trimmed.starts_with("~~~") { let marker = trimmed.chars().take(3).collect::<String>(); if !in_code { in_code = true; fence = marker.clone(); out.push(normalize_code_fence_line(line)); continue; } if trimmed.starts_with(&fence) { in_code = false; fence.clear(); out.push(line.to_string()); continue; } } if in_code { out.push(line.to_string()); continue; } let rewritten = rewrite_markdown_images(line); if contains_html_tag(&rewritten) { out.push(escape_html_line(&rewritten)); continue; } out.push(rewritten); } let mut joined = out.join("\n"); if content.ends_with('\n') { joined.push('\n'); } joined } fn rewrite_markdown_images(line: &str) -> String { let mut out = String::with_capacity(line.len()); let mut rest = line; while let Some(start) = rest.find("![") { out.push_str(&rest[..start]); let after_start = &rest[start + 2..]; let Some(end_bracket) = after_start.find(']') else { out.push_str(&rest[start..]); return out; }; let alt = &after_start[..end_bracket]; let after_bracket = &after_start[end_bracket + 1..]; let after_bracket_trim = after_bracket.trim_start(); if !after_bracket_trim.starts_with('(') { out.push_str(&rest[start..start + 2 + end_bracket + 1]); rest = &after_start[end_bracket + 1..]; continue; } let paren_offset = after_bracket.len() - after_bracket_trim.len(); let after_paren = &after_bracket[paren_offset + 1..]; let Some(end_paren) = after_paren.find(')') else { out.push_str(&rest[start..]); return out; }; let inner = &after_paren[..end_paren]; let dest = extract_markdown_dest(inner); if is_remote_image_dest(dest) { out.push_str("!["); out.push_str(alt); out.push_str("]("); out.push_str(inner); out.push(')'); } else { let label = if alt.trim().is_empty() { "image" } else { alt }; out.push('['); out.push_str(label); out.push_str("]("); out.push_str(inner); out.push(')'); } rest = &after_paren[end_paren + 1..]; } out.push_str(rest); out } fn extract_markdown_dest(inner: &str) -> &str { let trimmed = inner.trim_start(); if let Some(rest) = trimmed.strip_prefix('<') { if let Some(end) = rest.find('>') { return &rest[..end]; } } let mut end = trimmed.len(); for (idx, ch) in trimmed.char_indices() { if ch.is_whitespace() { end = idx; break; } } &trimmed[..end] } fn is_remote_image_dest(dest: &str) -> bool { let lower = dest.to_ascii_lowercase(); lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("data:") || lower.starts_with("mailto:") } fn normalize_code_fence_line(line: &str) -> String { let idx = line .char_indices() .find(|(_, ch)| !ch.is_whitespace()) .map(|(i, _)| i) .unwrap_or(0); let (prefix, trimmed) = line.split_at(idx); let fence = if trimmed.starts_with("```") { "```" } else if trimmed.starts_with("~~~") { "~~~" } else { return line.to_string(); }; let rest = &trimmed[fence.len()..]; let rest_trim_start = rest .char_indices() .find(|(_, ch)| !ch.is_whitespace()) .map(|(i, _)| i) .unwrap_or(rest.len()); let rest_trim = &rest[rest_trim_start..]; if rest_trim.is_empty() { return line.to_string(); } let (lang_token, _) = split_lang_token(rest_trim); let normalized = normalize_code_lang(lang_token); let Some(normalized) = normalized else { return line.to_string(); }; let after_lang = &rest[rest_trim_start + lang_token.len()..]; let before_lang = &rest[..rest_trim_start]; format!("{prefix}{fence}{before_lang}{normalized}{after_lang}") } fn split_lang_token(rest_trim: &str) -> (&str, &str) { let mut end = rest_trim.len(); for (idx, ch) in rest_trim.char_indices() { if ch.is_whitespace() || matches!(ch, '{' | '[' | '(') { end = idx; break; } } (&rest_trim[..end], &rest_trim[end..]) } fn normalize_code_lang(lang: &str) -> Option<&'static str> { let lower = lang.to_ascii_lowercase(); if lower.is_empty() { return None; } if matches!( lower.as_str(), "text" | "txt" | "plaintext" | "bash" | "sh" | "zsh" | "fish" | "shell" | "shellscript" | "console" | "json" | "yaml" | "yml" | "toml" | "ini" | "md" | "markdown" | "mdx" | "js" | "jsx" | "ts" | "tsx" | "py" | "python" | "rs" | "rust" | "go" | "c" | "cpp" | "cxx" | "java" | "kotlin" | "swift" | "rb" | "ruby" | "php" | "html" | "css" | "scss" | "less" | "sql" | "graphql" | "graphqls" | "dockerfile" | "make" | "makefile" ) { return None; } Some("text") } fn contains_html_tag(line: &str) -> bool { let bytes = line.as_bytes(); for i in 0..bytes.len() { if bytes[i] == b'<' { if i + 1 >= bytes.len() { continue; } let next = bytes[i + 1] as char; if next.is_ascii_alphabetic() || matches!(next, '/' | '!') { if line[i + 1..].contains('>') { return true; } } } } false } fn escape_html_line(line: &str) -> String { line.replace('<', "<").replace('>', ">") } fn ensure_docs_hub_config(hub_root: &Path) -> Result<()> { let ts_path = hub_root.join("source.config.ts"); let mjs_path = hub_root.join(".source").join("source.config.mjs"); if ts_path.exists() { rewrite_source_config(&ts_path, true)?; } if mjs_path.exists() { rewrite_source_config(&mjs_path, false)?; } Ok(()) } fn ensure_docs_hub_layout(hub_root: &Path) -> Result<()> { let page_path = hub_root .join("app") .join("(docs)") .join("[[...slug]]") .join("page.tsx"); if !page_path.exists() { return Ok(()); } fs::write(&page_path, DOCS_HUB_PAGE_TEMPLATE.as_bytes()) .with_context(|| format!("failed to write {}", page_path.display()))?; Ok(()) } const DOCS_HUB_PAGE_TEMPLATE: &str = r#"import { source } from "@/lib/source" import { DocsLayout } from "fumadocs-ui/layouts/docs" import { DocsPage, DocsBody, DocsDescription, DocsTitle } from "fumadocs-ui/page" import { notFound } from "next/navigation" import { useMDXComponents } from "@/mdx-components" import type { Metadata } from "next" type TreeNode = { name?: string url?: string path?: string slug?: string children?: TreeNode[] } function pickProjectTree(tree: TreeNode, slug?: string) { if (!slug || !tree || !Array.isArray(tree.children)) return tree const target = slug.toLowerCase() const child = tree.children.find((node) => { const url = String(node?.url ?? node?.path ?? "") if (url === `/${target}` || url === `${target}`) return true if (node?.slug && String(node.slug).toLowerCase() === target) return true if (node?.name && String(node.name).toLowerCase() === target) return true return false }) if (!child) return tree if (Array.isArray(child.children) && child.children.length > 0) { return { ...tree, children: child.children } } return { ...tree, children: [child] } } function navTitleForSlug(slug?: string) { if (!slug) return "Docs" const root = source.getPage([slug]) return root?.data?.title ?? slug } export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params const page = source.getPage(params.slug) if (!page) notFound() const rootSlug = params.slug?.[0] const tree = pickProjectTree(source.pageTree as TreeNode, rootSlug) const navTitle = navTitleForSlug(rootSlug) const MDX = page.data.body const mdxComponents = useMDXComponents() const navUrl = rootSlug ? `/${rootSlug}` : "/" return ( <DocsLayout tree={tree} nav={{ title: navTitle, url: navUrl }} sidebar={{ defaultOpenLevel: 1 }} > <DocsPage toc={page.data.toc} full={page.data.full}> <DocsTitle>{page.data.title}</DocsTitle> <DocsDescription>{page.data.description}</DocsDescription> <DocsBody> <MDX components={mdxComponents} /> </DocsBody> </DocsPage> </DocsLayout> ) } export const dynamicParams = false export async function generateStaticParams() { return source.generateParams() } export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }): Promise<Metadata> { const params = await props.params const page = source.getPage(params.slug) if (!page) notFound() return { title: page.data.title, description: page.data.description, } } "#; fn rewrite_source_config(path: &Path, is_ts: bool) -> Result<()> { let contents = if is_ts { r#"import { defineDocs, defineConfig } from "fumadocs-mdx/config" export const docs = defineDocs({ dir: "content/docs", }) export default defineConfig({ mdxOptions: { remarkImageOptions: { onError: "hide", external: { timeout: 1500 }, useImport: false, }, }, }) "# } else { r#"import { defineDocs, defineConfig } from "fumadocs-mdx/config" export const docs = defineDocs({ dir: "content/docs", }) export default defineConfig({ mdxOptions: { remarkImageOptions: { onError: "ignore", external: false, }, }, }) "# }; fs::write(path, contents.as_bytes()) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn docs_hub_needs_reset(hub_root: &Path) -> Result<bool> { let server_path = hub_root.join(".source").join("server.ts"); if !server_path.exists() { return Ok(false); } let contents = fs::read_to_string(&server_path) .with_context(|| format!("failed to read {}", server_path.display()))?; Ok(contents.contains("content/docs/projects/")) } fn remove_docs_hub_cache(hub_root: &Path) -> Result<()> { let source_root = hub_root.join(".source"); if source_root.exists() { fs::remove_dir_all(&source_root) .with_context(|| format!("failed to remove {}", source_root.display()))?; } let next_root = hub_root.join(".next"); if next_root.exists() { fs::remove_dir_all(&next_root) .with_context(|| format!("failed to remove {}", next_root.display()))?; } Ok(()) } fn kill_docs_hub_by_port(port: u16) -> Result<()> { #[cfg(unix)] { let port_arg = format!("tcp:{port}"); let output = Command::new("lsof").args(["-ti", &port_arg]).output(); let Ok(output) = output else { return Ok(()); }; if !output.status.success() { return Ok(()); } let pids = String::from_utf8_lossy(&output.stdout); for pid in pids.lines().map(str::trim).filter(|line| !line.is_empty()) { let _ = Command::new("kill").arg(pid).status(); } } Ok(()) } fn copy_template_dir(from: &Path, to: &Path) -> Result<()> { fs::create_dir_all(to).with_context(|| format!("failed to create {}", to.display()))?; for entry in fs::read_dir(from).with_context(|| format!("failed to read {}", from.display()))? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; let name = entry.file_name().to_string_lossy().to_string(); if file_type.is_dir() && should_skip_template_dir(&name) { continue; } let dest = to.join(entry.file_name()); if file_type.is_dir() { copy_template_dir(&path, &dest)?; } else if file_type.is_file() { fs::copy(&path, &dest).with_context(|| format!("failed to copy {}", path.display()))?; } } Ok(()) } fn sync_docs_hub_template_file( hub_root: &Path, template_root: &Path, rel_path: &str, overwrite: bool, ) -> Result<()> { let src = template_root.join(rel_path); if !src.exists() { return Ok(()); } let dest = hub_root.join(rel_path); if !overwrite && dest.exists() { return Ok(()); } if let Some(parent) = dest.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } fs::copy(&src, &dest).with_context(|| format!("failed to copy {}", src.display()))?; Ok(()) } fn ensure_docs_hub_flow_toml(hub_root: &Path, template_root: &Path) -> Result<()> { let src = template_root.join("flow.toml"); if !src.exists() { return Ok(()); } let dest = hub_root.join("flow.toml"); if !dest.exists() { fs::copy(&src, &dest).with_context(|| format!("failed to copy {}", src.display()))?; return Ok(()); } let dest_contents = fs::read_to_string(&dest).with_context(|| format!("failed to read {}", dest.display()))?; if dest_contents.contains("[cloudflare]") { return Ok(()); } let src_contents = fs::read_to_string(&src).with_context(|| format!("failed to read {}", src.display()))?; let Some(idx) = src_contents.find("[cloudflare]") else { return Ok(()); }; let mut updated = dest_contents; if !updated.ends_with('\n') { updated.push('\n'); } if !updated.trim().is_empty() { updated.push('\n'); } updated.push_str(src_contents[idx..].trim_start()); updated.push('\n'); fs::write(&dest, updated).with_context(|| format!("failed to write {}", dest.display()))?; Ok(()) } fn should_skip_template_dir(name: &str) -> bool { if matches!(name, ".source") { return false; } if name.starts_with('.') { return true; } matches!(name, "node_modules" | "dist" | "build" | ".next") } ================================================ FILE: src/doctor.rs ================================================ use std::{ env, fs::{self, OpenOptions}, io::{IsTerminal, Write}, path::{Path, PathBuf}, process::Command, }; use anyhow::{Context, Result, bail}; use crossterm::{event, terminal}; use crate::cli::DoctorOpts; use crate::vcs; /// Ensure the lin watcher daemon is available, prompting to install a bundled /// copy if it is missing from PATH. Returns the resolved binary path. pub fn ensure_lin_available_interactive() -> Result<PathBuf> { if let Ok(path) = which::which("lin") { println!("✅ lin watcher daemon found at {}", path.display()); return Ok(path); } if let Some(bundled) = find_bundled_lin() { if prompt_install_lin(&bundled)? { let installed = install_lin(&bundled)?; println!("✅ Installed lin to {}", installed.display()); return Ok(installed); } } bail!( "lin is not on PATH. Build/install from this repo (scripts/deploy.sh) so flow can delegate watchers to it." ); } pub fn run(_opts: DoctorOpts) -> Result<()> { println!("Running flow doctor checks...\n"); let zerobrew_available = ensure_zerobrew_available_interactive()?; ensure_flox_available(zerobrew_available)?; ensure_jj_available(zerobrew_available)?; let _ = ensure_lin_available_interactive(); ensure_direnv_on_path(zerobrew_available)?; match detect_shell()? { Some(shell) => ensure_shell_hook(shell)?, None => println!( "⚠️ Unable to detect your shell from $SHELL. Add the direnv hook manually (see https://direnv.net)." ), } println!("\n✅ flow doctor is done. Re-run it any time after changing shells or machines."); Ok(()) } fn ensure_flox_available(zerobrew_available: bool) -> Result<()> { if which::which("flox").is_ok() { println!("✅ flox found on PATH"); return Ok(()); } if maybe_install_with_zerobrew(zerobrew_available, "flox", "flox")? { if which::which("flox").is_ok() { println!("✅ flox installed via zerobrew"); return Ok(()); } } // Heuristic: flox-managed env leaves a .flox directory or ~/.flox directory. let home = home_dir(); if home.join(".flox").exists() { println!( "✅ flox environment directory detected at {}", home.join(".flox").display() ); return Ok(()); } bail!( "flox is not installed. Install it from https://flox.dev/docs/install-flox/install/ and re-run `f doctor`." ); } fn ensure_jj_available(zerobrew_available: bool) -> Result<()> { if which::which("jj").is_ok() { println!("✅ jj found on PATH"); return Ok(()); } if maybe_install_with_zerobrew(zerobrew_available, "jj", "jj")? { if which::which("jj").is_ok() { println!("✅ jj installed via zerobrew"); return Ok(()); } } vcs::ensure_jj_installed()?; println!("✅ jj found on PATH"); Ok(()) } fn ensure_direnv_on_path(zerobrew_available: bool) -> Result<()> { match which::which("direnv") { Ok(path) => { println!("✅ direnv found at {}", path.display()); Ok(()) } Err(_) => { if maybe_install_with_zerobrew(zerobrew_available, "direnv", "direnv")? { if let Ok(path) = which::which("direnv") { println!("✅ direnv installed via zerobrew at {}", path.display()); return Ok(()); } } bail!( "direnv is not on PATH. Install it from https://direnv.net/#installation and rerun `flow doctor`." ) } } } fn find_bundled_lin() -> Option<PathBuf> { let exe_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(PathBuf::from))?; let candidate = exe_dir.join("lin"); if candidate.exists() { Some(candidate) } else { None } } fn prompt_install_lin(bundled: &Path) -> Result<bool> { println!( "lin was not found on PATH. A bundled copy was found at {}.", bundled.display() ); print!( "Install lin to {}? [Y/n]: ", default_install_dir().display() ); let _ = std::io::stdout().flush(); let mut input = String::new(); std::io::stdin().read_line(&mut input).ok(); let normalized = input.trim().to_ascii_lowercase(); Ok(normalized.is_empty() || normalized == "y" || normalized == "yes") } fn ensure_zerobrew_available_interactive() -> Result<bool> { if which::which("zb").is_ok() { println!("✅ zerobrew (zb) found on PATH"); return Ok(true); } if !std::io::stdin().is_terminal() { println!("⚠️ zerobrew (zb) not found; skipping interactive install."); return Ok(false); } let install = prompt_yes("zerobrew (zb) not found. Install it now? [y/N]: ", false); if !install { return Ok(false); } let status = Command::new("/bin/sh") .arg("-c") .arg("curl -sSL https://raw.githubusercontent.com/lucasgelfond/zerobrew/main/install.sh | bash") .status() .context("failed to run zerobrew install script")?; if status.success() { if which::which("zb").is_ok() { println!("✅ zerobrew installed"); return Ok(true); } println!("⚠️ zerobrew installed but not on PATH yet; restart your shell."); return Ok(false); } println!("⚠️ zerobrew install failed"); Ok(false) } fn maybe_install_with_zerobrew( zerobrew_available: bool, tool: &str, package: &str, ) -> Result<bool> { if !zerobrew_available { return Ok(false); } if !std::io::stdin().is_terminal() { return Ok(false); } let prompt = format!("Install {} via zerobrew? [y/N]: ", tool); if !prompt_yes(&prompt, false) { return Ok(false); } let status = Command::new("zb") .arg("install") .arg(package) .status() .context("failed to run zb install")?; Ok(status.success()) } fn prompt_yes(prompt: &str, default_yes: bool) -> bool { print!("{}", prompt); let _ = std::io::stdout().flush(); if std::io::stdin().is_terminal() { if terminal::enable_raw_mode().is_ok() { let read = event::read(); let _ = terminal::disable_raw_mode(); if let Ok(event::Event::Key(key)) = read { let decision = match key.code { event::KeyCode::Char('y') | event::KeyCode::Char('Y') => Some(true), event::KeyCode::Char('n') | event::KeyCode::Char('N') => Some(false), event::KeyCode::Enter => Some(default_yes), event::KeyCode::Esc => Some(false), _ => None, }; if let Some(choice) = decision { println!(); return choice; } } } } let mut input = String::new(); std::io::stdin().read_line(&mut input).ok(); let normalized = input.trim().to_ascii_lowercase(); if normalized.is_empty() { return default_yes; } normalized == "y" || normalized == "yes" } fn install_lin(bundled: &Path) -> Result<PathBuf> { let dest_dir = default_install_dir(); std::fs::create_dir_all(&dest_dir).with_context(|| { format!( "failed to create lin install directory {}", dest_dir.display() ) })?; let dest = dest_dir.join("lin"); std::fs::copy(bundled, &dest).with_context(|| { format!( "failed to copy bundled lin from {} to {}", bundled.display(), dest.display() ) })?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&dest) .context("failed to stat installed lin")? .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&dest, perms).context("failed to mark lin executable")?; } Ok(dest) } fn default_install_dir() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .map(|home| home.join("bin")) .unwrap_or_else(|| PathBuf::from(".")) } fn detect_shell() -> Result<Option<ShellKind>> { if let Ok(shell_path) = env::var("SHELL") { if let Some(kind) = ShellKind::from_path(shell_path) { println!("✅ Detected shell: {}", kind.display()); return Ok(Some(kind)); } } Ok(None) } fn ensure_shell_hook(shell: ShellKind) -> Result<()> { let config_path = shell.config_path(); let indicator = shell.hook_indicator(); let snippet = shell.hook_snippet(); let existing = fs::read_to_string(&config_path).unwrap_or_default(); if existing.contains(indicator) { println!( "✅ {} already sources direnv ({}).", shell.display(), config_path.display() ); return Ok(()); } println!( "ℹ️ Adding direnv hook to {} ({}).", shell.display(), config_path.display() ); if let Some(parent) = config_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.to_string_lossy()))?; } let mut file = OpenOptions::new() .create(true) .append(true) .open(&config_path) .with_context(|| format!("failed to open {}", config_path.display()))?; if !existing.is_empty() && !existing.ends_with('\n') { writeln!(file)?; } writeln!(file, "\n# Added by flow doctor")?; writeln!(file, "{snippet}")?; println!( "✅ Added direnv hook for {}. Restart your shell or source {}.", shell.display(), config_path.display() ); Ok(()) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ShellKind { Bash, Zsh, Fish, } impl ShellKind { fn from_path<P: AsRef<Path>>(path: P) -> Option<Self> { let name = path .as_ref() .file_name() .map(|os| os.to_string_lossy().to_ascii_lowercase())?; match name.as_str() { "bash" => Some(Self::Bash), "zsh" => Some(Self::Zsh), "fish" => Some(Self::Fish), _ => None, } } fn display(&self) -> &'static str { match self { ShellKind::Bash => "bash", ShellKind::Zsh => "zsh", ShellKind::Fish => "fish", } } fn config_path(&self) -> PathBuf { let home = home_dir(); self.config_path_with_base(&home) } fn config_path_with_base(&self, home: &Path) -> PathBuf { match self { ShellKind::Bash => home.join(".bashrc"), ShellKind::Zsh => home.join(".zshrc"), ShellKind::Fish => home.join(".config/fish/config.fish"), } } fn hook_indicator(&self) -> &'static str { match self { ShellKind::Bash => "direnv hook bash", ShellKind::Zsh => "direnv hook zsh", ShellKind::Fish => "direnv hook fish", } } fn hook_snippet(&self) -> &'static str { match self { ShellKind::Bash => { r#"if command -v direnv >/dev/null 2>&1; then eval "$(direnv hook bash)" fi"# } ShellKind::Zsh => { r#"if command -v direnv >/dev/null 2>&1; then eval "$(direnv hook zsh)" fi"# } ShellKind::Fish => { r#"if type -q direnv direnv hook fish | source end"# } } } } fn home_dir() -> PathBuf { env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } #[cfg(test)] mod tests { use super::*; #[test] fn shell_detection_from_path() { assert_eq!(ShellKind::from_path("/bin/bash"), Some(ShellKind::Bash)); assert_eq!(ShellKind::from_path("zsh"), Some(ShellKind::Zsh)); assert_eq!( ShellKind::from_path("/usr/local/bin/fish"), Some(ShellKind::Fish) ); assert_eq!(ShellKind::from_path("/bin/sh"), None); } #[test] fn config_paths_follow_home_env() { let base = Path::new("/tmp/drflow"); assert_eq!( ShellKind::Zsh.config_path_with_base(base), PathBuf::from("/tmp/drflow/.zshrc") ); assert_eq!( ShellKind::Bash.config_path_with_base(base), PathBuf::from("/tmp/drflow/.bashrc") ); assert_eq!( ShellKind::Fish.config_path_with_base(base), PathBuf::from("/tmp/drflow/.config/fish/config.fish") ); } } ================================================ FILE: src/domains.rs ================================================ use std::collections::BTreeMap; use std::fs; use std::io::{Read, Write}; use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::thread; use std::time::Duration; use anyhow::{Context, Result, bail}; use crate::cli::{ DomainsAction, DomainsAddOpts, DomainsCommand, DomainsEngineArg, DomainsGetOpts, DomainsRmOpts, }; const PROXY_CONTAINER_NAME: &str = "flow-local-domains-proxy"; const NATIVE_PROXY_HEADER: &str = "x-flow-domainsd: 1"; const MACOS_DOMAINSD_LABEL: &str = "dev.flow.domainsd"; const COMPOSE_FILE: &str = r#"services: proxy: container_name: flow-local-domains-proxy image: nginx:1.27-alpine restart: unless-stopped ports: - "80:80" volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./routes:/etc/nginx/conf.d/routes:ro "#; const NGINX_MAIN_CONF: &str = r#"map $http_upgrade $connection_upgrade { default upgrade; "" close; } server { listen 80 default_server; server_name _; return 404 "No local Flow domain route configured for this host.\n"; } include /etc/nginx/conf.d/routes/*.conf; "#; #[derive(Debug)] struct DomainsPaths { root: PathBuf, compose: PathBuf, nginx_main: PathBuf, routes_dir: PathBuf, routes_state: PathBuf, native_pid: PathBuf, native_log: PathBuf, native_bin: PathBuf, } impl DomainsPaths { fn resolve() -> Result<Self> { let cfg = dirs::config_dir().context("Could not find config directory")?; let root = cfg.join("flow").join("local-domains"); Ok(Self { compose: root.join("docker-compose.yml"), nginx_main: root.join("nginx").join("default.conf"), routes_dir: root.join("routes"), routes_state: root.join("routes.json"), native_pid: root.join("domainsd.pid"), native_log: root.join("domainsd.log"), native_bin: root.join("domainsd-cpp"), root, }) } } pub fn run(cmd: DomainsCommand) -> Result<()> { let paths = DomainsPaths::resolve()?; let engine = resolve_engine(cmd.engine); match cmd.action { Some(DomainsAction::Up) => run_up(&paths, engine), Some(DomainsAction::Down) => run_down(&paths, engine), Some(DomainsAction::List) | None => run_list(&paths), Some(DomainsAction::Get(opts)) => run_get(&paths, opts), Some(DomainsAction::Add(opts)) => run_add(&paths, opts, engine), Some(DomainsAction::Rm(opts)) => run_rm(&paths, opts, engine), Some(DomainsAction::Doctor) => run_doctor(&paths, engine), } } #[derive(Debug, Clone, Copy, Eq, PartialEq)] enum DomainsEngine { Docker, Native, } fn resolve_engine(cli_engine: Option<DomainsEngineArg>) -> DomainsEngine { if let Some(engine) = cli_engine { return match engine { DomainsEngineArg::Docker => DomainsEngine::Docker, DomainsEngineArg::Native => DomainsEngine::Native, }; } match std::env::var("FLOW_DOMAINS_ENGINE") { Ok(v) if v.eq_ignore_ascii_case("native") => DomainsEngine::Native, _ => DomainsEngine::Docker, } } fn run_up(paths: &DomainsPaths, engine: DomainsEngine) -> Result<()> { if engine == DomainsEngine::Native { return run_up_native(paths); } ensure_docker_available()?; ensure_layout(paths)?; let routes = load_routes(paths)?; write_route_files(paths, &routes)?; assert_no_port_80_conflict()?; run_compose(paths, &["up", "-d"])?; println!("Local domains proxy is up (container: {PROXY_CONTAINER_NAME})."); println!("Config root: {}", paths.root.display()); println!("Routes: {}", routes.len()); if routes.is_empty() { println!("No routes yet. Add one with:"); println!(" f domains add linsa.localhost 127.0.0.1:3481"); } Ok(()) } fn run_down(paths: &DomainsPaths, engine: DomainsEngine) -> Result<()> { if engine == DomainsEngine::Native { return run_down_native(paths); } ensure_docker_available()?; ensure_layout(paths)?; run_compose(paths, &["down"])?; println!("Local domains proxy stopped."); Ok(()) } fn run_list(paths: &DomainsPaths) -> Result<()> { ensure_layout(paths)?; let routes = load_routes(paths)?; if routes.is_empty() { println!("No local domain routes configured."); println!("Add one with: f domains add linsa.localhost 127.0.0.1:3481"); return Ok(()); } println!("{:<32} {}", "HOST", "TARGET"); println!("{}", "-".repeat(58)); for (host, target) in routes { println!("{:<32} {}", host, target); } Ok(()) } fn run_get(paths: &DomainsPaths, opts: DomainsGetOpts) -> Result<()> { ensure_layout(paths)?; let host = normalize_host(&opts.host)?; let routes = load_routes(paths)?; let target = routes.get(&host).with_context(|| { format!( "Route not found: {}. Add it with `f domains add {} 127.0.0.1:<port>`.", host, host ) })?; if opts.target { println!("{target}"); } else { println!("http://{host}"); } Ok(()) } fn run_add(paths: &DomainsPaths, opts: DomainsAddOpts, engine: DomainsEngine) -> Result<()> { ensure_layout(paths)?; let host = normalize_host(&opts.host)?; let target = normalize_target(&opts.target)?; let mut routes = load_routes(paths)?; if let Some(existing) = routes.get(&host) { if existing == &target { println!("Route already exists: {host} -> {target}"); maybe_reload_running_proxy(paths, engine)?; return Ok(()); } if !opts.replace { bail!( "Route already exists: {} -> {}. Use --replace to update.", host, existing ); } } routes.insert(host.clone(), target.clone()); save_routes(paths, &routes)?; write_route_files(paths, &routes)?; maybe_reload_running_proxy(paths, engine)?; println!("Added route: {host} -> {target}"); Ok(()) } fn run_rm(paths: &DomainsPaths, opts: DomainsRmOpts, engine: DomainsEngine) -> Result<()> { ensure_layout(paths)?; let host = normalize_host(&opts.host)?; let mut routes = load_routes(paths)?; if routes.remove(&host).is_none() { bail!("Route not found: {}", host); } save_routes(paths, &routes)?; write_route_files(paths, &routes)?; maybe_reload_running_proxy(paths, engine)?; println!("Removed route: {host}"); Ok(()) } fn run_doctor(paths: &DomainsPaths, engine: DomainsEngine) -> Result<()> { if engine == DomainsEngine::Native { return run_doctor_native(paths); } ensure_layout(paths)?; let routes = load_routes(paths)?; println!("Local domains doctor"); println!("--------------------"); println!("Config root: {}", paths.root.display()); println!("Routes: {}", routes.len()); println!( "Docker: {}", if docker_available() { "available" } else { "missing" } ); let running = proxy_is_running()?; println!( "Proxy container: {}", if running { "running" } else { "stopped" } ); if let Some(owner) = docker_container_owning_port_80()? { if owner == PROXY_CONTAINER_NAME { println!("Port 80 owner: {} (expected)", owner); } else { println!("Port 80 owner: {} (conflict)", owner); } } else if let Some(listener) = port_80_listener_summary()? { println!("Port 80 listener: {}", listener); } else { println!("Port 80 listener: none"); } if !routes.is_empty() { println!(); println!("{:<32} {}", "HOST", "TARGET"); println!("{}", "-".repeat(58)); for (host, target) in routes { println!("{:<32} {}", host, target); } } Ok(()) } fn normalize_host(raw: &str) -> Result<String> { let mut host = raw.trim().to_ascii_lowercase(); if let Some(stripped) = host.strip_prefix("http://") { host = stripped.to_string(); } else if let Some(stripped) = host.strip_prefix("https://") { host = stripped.to_string(); } host = host.trim_end_matches('/').to_string(); if host.is_empty() { bail!("Host is empty"); } if host.contains('/') || host.contains(':') || host.contains(char::is_whitespace) { bail!("Host must be a hostname like linsa.localhost"); } if !host.ends_with(".localhost") { bail!("Host must end with .localhost"); } if host == ".localhost" || host == "localhost" { bail!("Host must include a subdomain (for example: linsa.localhost)"); } Ok(host) } fn normalize_target(raw: &str) -> Result<String> { let mut target = raw.trim().to_string(); if let Some(stripped) = target.strip_prefix("http://") { target = stripped.to_string(); } else if let Some(stripped) = target.strip_prefix("https://") { target = stripped.to_string(); } target = target.trim_end_matches('/').to_string(); if target.is_empty() { bail!("Target is empty"); } if target.contains('/') || target.contains('?') || target.contains('#') { bail!("Target must be host:port"); } let (host, port) = target .rsplit_once(':') .context("Target must include port (for example: 127.0.0.1:3481)")?; if host.trim().is_empty() { bail!("Target host is empty"); } let port_num = port .trim() .parse::<u16>() .context("Target port must be a valid number")?; Ok(format!("{}:{}", host.trim(), port_num)) } fn ensure_layout(paths: &DomainsPaths) -> Result<()> { fs::create_dir_all(paths.routes_dir.as_path())?; if let Some(parent) = paths.nginx_main.parent() { fs::create_dir_all(parent)?; } fs::write(&paths.compose, COMPOSE_FILE)?; fs::write(&paths.nginx_main, NGINX_MAIN_CONF)?; if !paths.routes_state.exists() { fs::write(&paths.routes_state, "{}\n")?; } Ok(()) } fn load_routes(paths: &DomainsPaths) -> Result<BTreeMap<String, String>> { if !paths.routes_state.exists() { return Ok(BTreeMap::new()); } let raw = fs::read_to_string(&paths.routes_state)?; let parsed: BTreeMap<String, String> = serde_json::from_str(&raw).context("Failed to parse routes.json")?; Ok(parsed) } fn save_routes(paths: &DomainsPaths, routes: &BTreeMap<String, String>) -> Result<()> { let payload = serde_json::to_string_pretty(routes)?; fs::write(&paths.routes_state, format!("{payload}\n"))?; Ok(()) } fn write_route_files(paths: &DomainsPaths, routes: &BTreeMap<String, String>) -> Result<()> { fs::create_dir_all(&paths.routes_dir)?; for entry in fs::read_dir(&paths.routes_dir)? { let entry = entry?; let path = entry.path(); if path .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext == "conf") .unwrap_or(false) { fs::remove_file(path)?; } } for (host, target) in routes { let file = paths.routes_dir.join(route_file_name(host)); fs::write(file, render_route(host, target))?; } Ok(()) } fn route_file_name(host: &str) -> String { let mut safe = String::with_capacity(host.len()); for ch in host.chars() { if ch.is_ascii_alphanumeric() || ch == '-' { safe.push(ch); } else { safe.push('_'); } } format!("{safe}.conf") } fn render_route(host: &str, target: &str) -> String { let (upstream_target, host_header) = docker_upstream(target); format!( r#"server {{ listen 80; server_name {host}; location / {{ proxy_pass http://{upstream_target}; proxy_http_version 1.1; proxy_set_header Host {host_header}; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; }} }} "# ) } fn docker_upstream(target: &str) -> (String, String) { let Some((host, port)) = target.rsplit_once(':') else { return (target.to_string(), target.to_string()); }; match host { "127.0.0.1" | "localhost" | "::1" => ( format!("host.docker.internal:{}", port), "localhost".to_string(), ), _ => (format!("{}:{}", host, port), host.to_string()), } } fn ensure_docker_available() -> Result<()> { if docker_available() { Ok(()) } else { bail!("docker is required for local domains. Install Docker/OrbStack first.") } } fn docker_available() -> bool { which::which("docker").is_ok() } fn run_compose(paths: &DomainsPaths, args: &[&str]) -> Result<()> { let output = Command::new("docker") .arg("compose") .arg("-f") .arg(&paths.compose) .args(args) .output() .context("Failed to run docker compose")?; ensure_success(output, "docker compose command failed") } fn ensure_success(output: Output, context_msg: &str) -> Result<()> { if output.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let detail = if !stderr.is_empty() { stderr } else { stdout }; if detail.is_empty() { bail!("{context_msg}"); } bail!("{context_msg}: {detail}"); } fn maybe_reload_running_proxy(paths: &DomainsPaths, engine: DomainsEngine) -> Result<()> { match engine { DomainsEngine::Docker => maybe_reload_running_proxy_docker(), DomainsEngine::Native => { if native_proxy_running(paths)? { // Native daemon reloads routes.json lazily on mtime change. return Ok(()); } println!("Native proxy not running yet. Start it with: f domains --engine native up"); Ok(()) } } } fn maybe_reload_running_proxy_docker() -> Result<()> { if !docker_available() { return Ok(()); } if !proxy_is_running()? { println!("Proxy not running yet. Start it with: f domains up"); return Ok(()); } let output = Command::new("docker") .args(["exec", PROXY_CONTAINER_NAME, "nginx", "-s", "reload"]) .output() .context("Failed to reload proxy")?; ensure_success(output, "Failed to reload running proxy") } fn proxy_is_running() -> Result<bool> { if !docker_available() { return Ok(false); } let output = Command::new("docker") .args([ "ps", "--filter", &format!("name=^/{}$", PROXY_CONTAINER_NAME), "--format", "{{.Names}}", ]) .output() .context("Failed to check docker container status")?; if !output.status.success() { return Ok(false); } let names = String::from_utf8_lossy(&output.stdout); Ok(names .lines() .any(|line| line.trim() == PROXY_CONTAINER_NAME)) } fn docker_container_owning_port_80() -> Result<Option<String>> { if !docker_available() { return Ok(None); } let output = Command::new("docker") .args(["ps", "--format", "{{.Names}}\t{{.Ports}}"]) .output() .context("Failed to inspect docker port bindings")?; if !output.status.success() { return Ok(None); } let text = String::from_utf8_lossy(&output.stdout); for line in text.lines() { let mut parts = line.splitn(2, '\t'); let name = parts.next().unwrap_or("").trim(); let ports = parts.next().unwrap_or(""); if ports.contains(":80->80/tcp") { return Ok(Some(name.to_string())); } } Ok(None) } fn assert_no_port_80_conflict() -> Result<()> { if let Some(owner) = docker_container_owning_port_80()? { if owner != PROXY_CONTAINER_NAME { bail!( "Port 80 is already owned by docker container '{}'. Stop it first (for example: docker stop {}).", owner, owner ); } return Ok(()); } if proxy_is_running()? { return Ok(()); } if let Some(listener) = port_80_listener_summary()? { bail!( "Port 80 is already in use by '{}'. Stop that listener, then retry `f domains up`.", listener ); } Ok(()) } fn port_80_listener_summary() -> Result<Option<String>> { if which::which("lsof").is_err() { return Ok(None); } let output = Command::new("lsof") .args(["-nP", "-iTCP:80", "-sTCP:LISTEN"]) .output() .context("Failed to inspect port 80 listeners")?; if !output.status.success() { return Ok(None); } let text = String::from_utf8_lossy(&output.stdout); let mut lines = text.lines(); let _header = lines.next(); if let Some(line) = lines.next() { let compact = line.split_whitespace().collect::<Vec<_>>().join(" "); return Ok(Some(compact)); } Ok(None) } fn run_up_native(paths: &DomainsPaths) -> Result<()> { ensure_layout(paths)?; let routes = load_routes(paths)?; assert_no_port_80_conflict_native(paths)?; ensure_native_binary(paths)?; if native_proxy_running(paths)? { println!("Native local domains proxy is already running."); println!("Config root: {}", paths.root.display()); println!("Routes: {}", routes.len()); return Ok(()); } start_native_proxy(paths)?; println!("Native local domains proxy is up (binary: domainsd-cpp)."); println!("Config root: {}", paths.root.display()); println!("Routes: {}", routes.len()); if routes.is_empty() { println!("No routes yet. Add one with:"); println!(" f domains add linsa.localhost 127.0.0.1:3481"); } Ok(()) } fn run_down_native(paths: &DomainsPaths) -> Result<()> { ensure_layout(paths)?; if cfg!(target_os = "macos") { let launchd_plist = macos_launchd_plist_path(); if launchd_plist.exists() { println!( "Native local domains appears launchd-managed: {}", launchd_plist.display() ); println!( "To stop/uninstall launchd mode, run:\n sudo {}", macos_launchd_uninstall_script_path().display() ); return Ok(()); } } let Some(pid) = read_native_pid(paths)? else { println!("Native local domains proxy is not running."); return Ok(()); }; if !pid_alive(pid) { let _ = fs::remove_file(&paths.native_pid); println!("Native local domains proxy was not running (removed stale pid file)."); return Ok(()); } let output = Command::new("kill") .args(["-TERM", &pid.to_string()]) .output() .context("Failed to stop native local domains proxy")?; if !output.status.success() { ensure_success(output, "Failed to stop native local domains proxy")?; } for _ in 0..40 { if !pid_alive(pid) { break; } thread::sleep(Duration::from_millis(50)); } let _ = fs::remove_file(&paths.native_pid); println!("Native local domains proxy stopped."); Ok(()) } fn run_doctor_native(paths: &DomainsPaths) -> Result<()> { ensure_layout(paths)?; let routes = load_routes(paths)?; let running = native_proxy_running(paths)?; println!("Local domains doctor"); println!("--------------------"); println!("Engine: native"); println!("Config root: {}", paths.root.display()); println!("Routes: {}", routes.len()); println!( "Native daemon: {}", if running { "running" } else { "stopped" } ); println!("Native binary: {}", paths.native_bin.display()); println!("Native pid file: {}", paths.native_pid.display()); println!("Native log file: {}", paths.native_log.display()); if let Some(owner) = docker_container_owning_port_80()? { println!("Port 80 docker owner: {}", owner); } else if let Some(listener) = port_80_listener_summary()? { println!("Port 80 listener: {}", listener); } else { println!("Port 80 listener: none"); } println!( "Native health: {}", if native_healthcheck().unwrap_or(false) { "ok" } else { "unreachable" } ); if !routes.is_empty() { println!(); println!("{:<32} {}", "HOST", "TARGET"); println!("{}", "-".repeat(58)); for (host, target) in routes { println!("{:<32} {}", host, target); } } Ok(()) } fn native_source_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tools") .join("domainsd-cpp") .join("domainsd.cpp") } fn domainsd_tools_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tools") .join("domainsd-cpp") } fn macos_launchd_install_script_path() -> PathBuf { domainsd_tools_dir().join("install-macos-launchd.sh") } fn macos_launchd_uninstall_script_path() -> PathBuf { domainsd_tools_dir().join("uninstall-macos-launchd.sh") } fn macos_launchd_plist_path() -> PathBuf { PathBuf::from("/Library/LaunchDaemons").join(format!("{MACOS_DOMAINSD_LABEL}.plist")) } fn log_contains_permission_denied(path: &Path) -> Result<bool> { if !path.exists() { return Ok(false); } let content = fs::read_to_string(path) .with_context(|| format!("Failed to read native log {}", path.display()))?; Ok(content.contains("Permission denied")) } fn ensure_native_binary(paths: &DomainsPaths) -> Result<()> { let source = native_source_path(); if !source.exists() { bail!( "Native domains daemon source not found at {}", source.display() ); } let rebuild = if !paths.native_bin.exists() { true } else { let src_mtime = fs::metadata(&source) .and_then(|m| m.modified()) .context("Failed to read native daemon source mtime")?; let bin_mtime = fs::metadata(&paths.native_bin) .and_then(|m| m.modified()) .context("Failed to read native daemon binary mtime")?; src_mtime > bin_mtime }; if !rebuild { return Ok(()); } let compiler = if std::path::Path::new("/usr/bin/clang++").exists() { PathBuf::from("/usr/bin/clang++") } else { which::which("clang++") .context("clang++ is required for --engine native (install Xcode command line tools)")? }; let output = Command::new(&compiler) .args([ "-std=c++20", "-O3", "-DNDEBUG", "-Wall", "-Wextra", "-pthread", source.to_string_lossy().as_ref(), "-o", paths.native_bin.to_string_lossy().as_ref(), ]) .output() .context("Failed to build native local domains daemon")?; ensure_success(output, "Failed to build native local domains daemon")?; Ok(()) } fn assert_no_port_80_conflict_native(paths: &DomainsPaths) -> Result<()> { if native_proxy_running(paths)? { return Ok(()); } if let Some(owner) = docker_container_owning_port_80()? { bail!( "Port 80 is owned by docker container '{}'. Stop it first before starting native domains proxy.", owner ); } if let Some(listener) = port_80_listener_summary()? { bail!( "Port 80 is already in use by '{}'. Stop that listener, then retry `f domains --engine native up`.", listener ); } Ok(()) } fn start_native_proxy(paths: &DomainsPaths) -> Result<()> { let log_file = fs::OpenOptions::new() .create(true) .append(true) .open(&paths.native_log) .with_context(|| format!("Failed to open log file {}", paths.native_log.display()))?; let err_file = log_file .try_clone() .context("Failed to duplicate native proxy log file handle")?; let mut cmd = Command::new(&paths.native_bin); cmd.arg("--listen") .arg("127.0.0.1:80") .arg("--routes") .arg(&paths.routes_state) .arg("--pidfile") .arg(&paths.native_pid); for (flag, value) in native_tuning_args()? { cmd.arg(flag).arg(value); } let child = cmd .stdout(log_file) .stderr(err_file) .spawn() .context("Failed to spawn native local domains daemon")?; let pid = child.id(); for _ in 0..50 { if native_healthcheck().unwrap_or(false) { return Ok(()); } thread::sleep(Duration::from_millis(100)); } if cfg!(target_os = "macos") && log_contains_permission_denied(&paths.native_log)? { bail!( "Native local domains proxy failed to bind port 80 (permission denied).\n\ macOS requires privileged socket ownership for :80 in native mode.\n\ Run once:\n sudo {}\n\ Then retry: f domains --engine native up\n\ Log: {}", macos_launchd_install_script_path().display(), paths.native_log.display() ); } bail!( "Native local domains proxy failed to become healthy (pid {}). Check logs: {}", pid, paths.native_log.display() ) } fn native_tuning_args() -> Result<Vec<(&'static str, String)>> { const MAPPINGS: [(&str, &str); 8] = [ ( "FLOW_DOMAINS_NATIVE_MAX_ACTIVE_CLIENTS", "--max-active-clients", ), ( "FLOW_DOMAINS_NATIVE_UPSTREAM_CONNECT_TIMEOUT_MS", "--upstream-connect-timeout-ms", ), ( "FLOW_DOMAINS_NATIVE_UPSTREAM_IO_TIMEOUT_MS", "--upstream-io-timeout-ms", ), ( "FLOW_DOMAINS_NATIVE_CLIENT_IO_TIMEOUT_MS", "--client-io-timeout-ms", ), ( "FLOW_DOMAINS_NATIVE_POOL_MAX_IDLE_PER_KEY", "--pool-max-idle-per-key", ), ( "FLOW_DOMAINS_NATIVE_POOL_MAX_IDLE_TOTAL", "--pool-max-idle-total", ), ( "FLOW_DOMAINS_NATIVE_POOL_IDLE_TIMEOUT_MS", "--pool-idle-timeout-ms", ), ("FLOW_DOMAINS_NATIVE_POOL_MAX_AGE_MS", "--pool-max-age-ms"), ]; let mut out = Vec::new(); for (env_name, flag) in MAPPINGS { if let Ok(raw) = std::env::var(env_name) { let parsed = raw .trim() .parse::<u64>() .with_context(|| format!("Invalid {} value: {}", env_name, raw))?; if parsed == 0 { bail!("{} must be > 0", env_name); } out.push((flag, parsed.to_string())); } } Ok(out) } fn read_native_pid(paths: &DomainsPaths) -> Result<Option<u32>> { if !paths.native_pid.exists() { return Ok(None); } let raw = fs::read_to_string(&paths.native_pid) .with_context(|| format!("Failed to read {}", paths.native_pid.display()))?; let parsed = raw .trim() .parse::<u32>() .with_context(|| format!("Invalid pid in {}", paths.native_pid.display()))?; Ok(Some(parsed)) } fn pid_alive(pid: u32) -> bool { Command::new("kill") .args(["-0", &pid.to_string()]) .status() .map(|s| s.success()) .unwrap_or(false) } fn native_proxy_running(paths: &DomainsPaths) -> Result<bool> { let Some(pid) = read_native_pid(paths)? else { return Ok(false); }; if !pid_alive(pid) { return Ok(false); } Ok(native_healthcheck().unwrap_or(false)) } fn native_healthcheck() -> Result<bool> { let mut stream = match TcpStream::connect("127.0.0.1:80") { Ok(s) => s, Err(_) => return Ok(false), }; stream.set_read_timeout(Some(Duration::from_millis(250)))?; stream.set_write_timeout(Some(Duration::from_millis(250)))?; stream.write_all( b"GET /_flow/domains/health HTTP/1.1\r\nHost: flow-domains-health.localhost\r\nConnection: close\r\n\r\n", )?; let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf)?; if n == 0 { return Ok(false); } let response = String::from_utf8_lossy(&buf[..n]).to_ascii_lowercase(); Ok(response.contains(NATIVE_PROXY_HEADER)) } #[cfg(test)] mod tests { use super::*; #[test] fn render_route_uses_localhost_host_header_for_loopback_targets() { let rendered = render_route("linsa.localhost", "127.0.0.1:3481"); assert!(rendered.contains("proxy_pass http://host.docker.internal:3481;")); assert!(rendered.contains("proxy_set_header Host localhost;")); } #[test] fn normalize_host_requires_localhost_suffix() { assert!(normalize_host("linsa.localhost").is_ok()); assert!(normalize_host("linsa.dev").is_err()); } #[test] fn normalize_target_requires_port() { assert!(normalize_target("127.0.0.1:3481").is_ok()); assert!(normalize_target("127.0.0.1").is_err()); } } ================================================ FILE: src/env.rs ================================================ //! Environment variable management via the cloud backend with local fallback. //! //! Fetches, sets, and manages environment variables for projects //! using the cloud API, with optional local storage when needed. use std::collections::{HashMap, HashSet}; use std::fs::{self, OpenOptions}; use std::io::{self, IsTerminal, Write}; #[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use chrono::{DateTime, Local, TimeZone, Utc}; use crypto_secretbox::{ XSalsa20Poly1305, aead::{Aead, KeyInit}, }; use rand::{TryRng, rngs::SysRng}; use reqwest::Url; use serde::{Deserialize, Serialize}; use which::which; use crate::agent_setup; use crate::cli::{EnvAction, ProjectEnvAction, TokenAction}; use crate::config; use crate::deploy; use crate::env_setup::{EnvSetupDefaults, run_env_setup}; use crate::sealer_crypto::{get_sealer_id, new_x25519_private_key, seal, unseal}; use crate::storage::{ create_jazz_app_credentials, get_project_name as storage_project_name, sanitize_name, }; use uuid::Uuid; const DEFAULT_API_URL: &str = "https://myflow.sh"; const LOCAL_ENV_DIR: &str = "env-local"; const LOCAL_KEYCHAIN_REF_PREFIX: &str = "flow-keychain-ref://v1/"; const ENV_SEALER_SECRET_PREFIX: &str = "sealerSecret_z"; const SEALED_ENV_ALGORITHM: &str = "xsalsa20poly1305+flow-sealer-v1"; /// Auth config stored in ~/.config/flow/auth.toml #[derive(Debug, Serialize, Deserialize, Default)] struct AuthConfig { token: Option<String>, api_url: Option<String>, token_source: Option<String>, ai_token: Option<String>, ai_api_url: Option<String>, ai_token_source: Option<String>, } #[derive(Debug, Serialize, Deserialize)] struct EnvReadUnlock { expires_at: i64, } /// An env var with optional description. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct EnvVar { pub value: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option<String>, } /// Response from /api/env/:projectName #[derive(Debug, Deserialize)] struct EnvResponse { env: HashMap<String, String>, #[serde(default)] descriptions: HashMap<String, String>, } /// Response from POST /api/env/:projectName #[derive(Debug, Deserialize)] #[allow(dead_code)] struct SetEnvResponse { success: bool, project: String, environment: String, } /// Response from /api/env/personal #[derive(Debug, Deserialize)] struct PersonalEnvResponse { env: HashMap<String, String>, } #[derive(Debug, Serialize, Deserialize)] struct EnvSealerIdentity { sealer_secret: String, sealer_id: String, } #[derive(Debug, Deserialize)] struct ProjectSealersResponse { members: Vec<ProjectSealerMember>, } #[derive(Debug, Deserialize)] struct ProjectSealerMember { #[serde(default)] sealers: Vec<ProjectSealerEntry>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ProjectSealerEntry { sealer_id: String, } #[derive(Debug, Deserialize)] struct SealedEnvResponse { items: HashMap<String, SealedEnvItem>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SealedEnvItem { #[serde(default)] description: Option<String>, #[serde(default)] available_recipient_count: usize, content: Option<SealedEnvContent>, #[serde(default)] recipients: Vec<SealedEnvRecipientGrant>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SealedEnvContent { algorithm: String, ciphertext_b64: String, nonce_b64: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SealedEnvRecipientGrant { recipient_id: String, sender_id: Option<String>, wrapped_key_b64: String, nonce_material_b64: String, } #[derive(Debug, Serialize)] struct SealedEnvWriteRequest { environment: String, items: HashMap<String, SealedEnvWriteItem>, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct SealedEnvWriteItem { #[serde(skip_serializing_if = "Option::is_none")] description: Option<String>, classification: String, content: SealedEnvWriteContent, recipients: Vec<SealedEnvWriteRecipient>, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct SealedEnvWriteContent { algorithm: String, ciphertext_b64: String, nonce_b64: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct SealedEnvWriteRecipient { recipient_id: String, recipient_kind: String, sender_id: String, wrapped_key_b64: String, nonce_material_b64: String, } #[derive(Debug, Default)] struct ProjectCloudEnvEntries { vars: HashMap<String, String>, descriptions: HashMap<String, String>, } /// Get the auth config path. fn get_auth_config_path() -> PathBuf { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("flow"); config_dir.join("auth.toml") } /// Load auth config. fn load_auth_config_raw() -> Result<AuthConfig> { let path = get_auth_config_path(); if !path.exists() { return Ok(AuthConfig::default()); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; toml::from_str(&content).context("failed to parse auth.toml") } /// Load auth config and hydrate token from Keychain on macOS when configured. fn load_auth_config() -> Result<AuthConfig> { let mut auth = load_auth_config_raw()?; if auth.token.is_none() && auth .token_source .as_deref() .map(|source| source == "keychain") .unwrap_or(false) { require_env_read_unlock()?; if let Some(token) = get_keychain_token(&get_api_url(&auth))? { auth.token = Some(token); } } Ok(auth) } fn load_ai_auth_config() -> Result<AuthConfig> { let mut auth = load_auth_config_raw()?; if auth.ai_token.is_none() && auth .ai_token_source .as_deref() .map(|source| source == "keychain") .unwrap_or(false) { if let Some(token) = get_keychain_ai_token(&get_ai_api_url(&auth))? { auth.ai_token = Some(token); } } Ok(auth) } /// Save auth config. fn save_auth_config(config: &AuthConfig) -> Result<()> { let path = get_auth_config_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = toml::to_string_pretty(config)?; fs::write(&path, content)?; Ok(()) } fn keychain_service(api_url: &str) -> String { format!("flow-cloud-token:{}", api_url) } fn keychain_service_ai(api_url: &str) -> String { format!("flow-ai-token:{}", api_url) } fn local_env_keychain_service(target: &EnvTarget, environment: &str) -> String { let env_name = if environment.trim().is_empty() { "production" } else { environment }; format!( "flow-local-env:{}:{}", sanitize_env_segment(&env_target_label(target)), sanitize_env_segment(env_name) ) } fn set_keychain_token(api_url: &str, token: &str) -> Result<()> { let service = keychain_service(api_url); let status = Command::new("security") .args([ "add-generic-password", "-a", "flow", "-s", &service, "-w", token, "-U", ]) .status() .context("failed to store token in Keychain")?; if !status.success() { bail!("failed to store token in Keychain"); } Ok(()) } fn set_keychain_ai_token(api_url: &str, token: &str) -> Result<()> { let service = keychain_service_ai(api_url); let status = Command::new("security") .args([ "add-generic-password", "-a", "flow", "-s", &service, "-w", token, "-U", ]) .status() .context("failed to store AI token in Keychain")?; if !status.success() { bail!("failed to store AI token in Keychain"); } Ok(()) } fn get_keychain_token(api_url: &str) -> Result<Option<String>> { if !cfg!(target_os = "macos") { return Ok(None); } let service = keychain_service(api_url); let output = Command::new("security") .args(["find-generic-password", "-a", "flow", "-s", &service, "-w"]) .output() .context("failed to read token from Keychain")?; if output.status.success() { let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); if token.is_empty() { return Ok(None); } return Ok(Some(token)); } let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("could not be found") || stderr.contains("SecKeychainSearchCopyNext") { return Ok(None); } bail!("failed to read token from Keychain: {}", stderr.trim()); } fn get_keychain_ai_token(api_url: &str) -> Result<Option<String>> { if !cfg!(target_os = "macos") { return Ok(None); } let service = keychain_service_ai(api_url); let output = Command::new("security") .args(["find-generic-password", "-a", "flow", "-s", &service, "-w"]) .output() .context("failed to read AI token from Keychain")?; if output.status.success() { let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); if token.is_empty() { return Ok(None); } return Ok(Some(token)); } let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("could not be found") || stderr.contains("SecKeychainSearchCopyNext") { return Ok(None); } bail!("failed to read AI token from Keychain: {}", stderr.trim()); } fn set_local_keychain_env_var( target: &EnvTarget, environment: &str, key: &str, value: &str, ) -> Result<()> { if !cfg!(target_os = "macos") { bail!("local keychain-backed envs require macOS"); } let service = local_env_keychain_service(target, environment); let status = Command::new("security") .args([ "add-generic-password", "-a", key, "-s", &service, "-w", value, "-U", ]) .status() .context("failed to store local env var in Keychain")?; if !status.success() { bail!("failed to store local env var in Keychain"); } Ok(()) } fn get_local_keychain_env_var( target: &EnvTarget, environment: &str, key: &str, ) -> Result<Option<String>> { if !cfg!(target_os = "macos") { return Ok(None); } let service = local_env_keychain_service(target, environment); let output = Command::new("security") .args(["find-generic-password", "-a", key, "-s", &service, "-w"]) .output() .context("failed to read local env var from Keychain")?; if output.status.success() { let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); if value.is_empty() { return Ok(None); } return Ok(Some(value)); } let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("could not be found") || stderr.contains("SecKeychainSearchCopyNext") { return Ok(None); } bail!( "failed to read local env var from Keychain: {}", stderr.trim() ); } fn delete_local_keychain_env_var(target: &EnvTarget, environment: &str, key: &str) -> Result<()> { if !cfg!(target_os = "macos") { return Ok(()); } let service = local_env_keychain_service(target, environment); let output = Command::new("security") .args(["delete-generic-password", "-a", key, "-s", &service]) .output() .context("failed to delete local env var from Keychain")?; if output.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("could not be found") || stderr.contains("SecKeychainSearchCopyNext") { return Ok(()); } bail!( "failed to delete local env var from Keychain: {}", stderr.trim() ); } fn store_auth_token(auth: &mut AuthConfig, token: String) -> Result<()> { let api_url = get_api_url(auth); if cfg!(target_os = "macos") { if let Err(err) = set_keychain_token(&api_url, &token) { eprintln!("⚠ Failed to store token in Keychain: {}", err); eprintln!(" Falling back to auth.toml storage."); auth.token = Some(token); auth.token_source = None; return Ok(()); } auth.token = None; auth.token_source = Some("keychain".to_string()); } else { auth.token = Some(token); auth.token_source = None; } Ok(()) } fn store_ai_auth_token(auth: &mut AuthConfig, token: String) -> Result<()> { let api_url = get_ai_api_url(auth); if cfg!(target_os = "macos") { if let Err(err) = set_keychain_ai_token(&api_url, &token) { eprintln!("⚠ Failed to store AI token in Keychain: {}", err); eprintln!(" Falling back to auth.toml storage."); auth.ai_token = Some(token); auth.ai_token_source = None; return Ok(()); } auth.ai_token = None; auth.ai_token_source = Some("keychain".to_string()); } else { auth.ai_token = Some(token); auth.ai_token_source = None; } Ok(()) } fn get_env_unlock_path() -> PathBuf { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("flow"); config_dir.join("env_read_unlock.json") } fn load_env_unlock() -> Option<EnvReadUnlock> { let path = get_env_unlock_path(); let content = fs::read_to_string(&path).ok()?; serde_json::from_str(&content).ok() } fn unlock_expires_at(entry: &EnvReadUnlock) -> Option<DateTime<Utc>> { Utc.timestamp_opt(entry.expires_at, 0).single() } fn save_env_unlock(expires_at: DateTime<Utc>) -> Result<()> { let path = get_env_unlock_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let entry = EnvReadUnlock { expires_at: expires_at.timestamp(), }; let content = serde_json::to_string_pretty(&entry)?; fs::write(&path, content)?; Ok(()) } fn next_local_midnight_utc() -> Result<DateTime<Utc>> { let now = Local::now(); let tomorrow = now .date_naive() .succ_opt() .ok_or_else(|| anyhow::anyhow!("failed to calculate next day"))?; let naive = tomorrow .and_hms_opt(0, 0, 0) .ok_or_else(|| anyhow::anyhow!("failed to build midnight time"))?; let local_dt = Local .from_local_datetime(&naive) .single() .or_else(|| Local.from_local_datetime(&naive).earliest()) .ok_or_else(|| anyhow::anyhow!("failed to resolve local midnight"))?; Ok(local_dt.with_timezone(&Utc)) } fn prompt_touch_id() -> Result<()> { if !cfg!(target_os = "macos") { bail!("Touch ID is not available on this OS"); } if std::env::var("FLOW_NO_TOUCH_ID").is_ok() || !std::io::stdin().is_terminal() { bail!("Touch ID prompt requires an interactive terminal"); } let reason = "Flow needs Touch ID to read env vars."; let reason = reason.replace('\\', "\\\\").replace('"', "\\\""); let script = format!( r#"ObjC.import('stdlib'); ObjC.import('Foundation'); ObjC.import('LocalAuthentication'); const context = $.LAContext.alloc.init; const policy = $.LAPolicyDeviceOwnerAuthenticationWithBiometrics; const error = Ref(); if (!context.canEvaluatePolicyError(policy, error)) {{ $.exit(2); }} let ok = false; let done = false; context.evaluatePolicyLocalizedReasonReply(policy, "{reason}", function(success, err) {{ ok = success; done = true; }}); const runLoop = $.NSRunLoop.currentRunLoop; while (!done) {{ runLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1)); }} $.exit(ok ? 0 : 1);"# ); let status = Command::new("osascript") .args(["-l", "JavaScript", "-e", &script]) .status() .context("failed to launch Touch ID prompt")?; match status.code() { Some(0) => Ok(()), Some(1) => bail!("Touch ID verification failed"), Some(2) => bail!("Touch ID is not available on this device"), _ => bail!("Touch ID verification failed"), } } fn unlock_env_read() -> Result<()> { if !cfg!(target_os = "macos") { println!("Touch ID unlock is not available on this OS."); return Ok(()); } if let Some(entry) = load_env_unlock() { if let Some(expires_at) = unlock_expires_at(&entry) { if expires_at > Utc::now() { let local_expiry = expires_at.with_timezone(&Local); println!( "Env read access already unlocked until {}", local_expiry.format("%Y-%m-%d %H:%M %Z") ); return Ok(()); } } } println!("Touch ID required to read env vars."); prompt_touch_id()?; let expires_at = next_local_midnight_utc()?; save_env_unlock(expires_at)?; let local_expiry = expires_at.with_timezone(&Local); println!( "✓ Env read access unlocked until {}", local_expiry.format("%Y-%m-%d %H:%M %Z") ); Ok(()) } fn require_env_read_unlock() -> Result<()> { if !cfg!(target_os = "macos") { return Ok(()); } if let Some(entry) = load_env_unlock() { if let Some(expires_at) = unlock_expires_at(&entry) { if expires_at > Utc::now() { return Ok(()); } } } unlock_env_read() } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EnvScope { Project, Personal, } #[derive(Debug, Clone)] struct EnvTargetConfig { project_name: String, env_space: Option<String>, env_space_kind: EnvScope, } #[derive(Debug, Clone)] enum EnvTarget { Project { name: String }, Personal { space: Option<String> }, } fn parse_env_space_kind(value: Option<&str>) -> EnvScope { match value.map(|s| s.trim().to_ascii_lowercase()) { Some(ref v) if v == "personal" || v == "user" || v == "private" => EnvScope::Personal, _ => EnvScope::Project, } } fn load_env_target_config() -> Result<EnvTargetConfig> { let cwd = std::env::current_dir()?; if let Some(flow_path) = find_flow_toml(&cwd) { let cfg = config::load(&flow_path)?; let project_name = if let Some(name) = cfg.project_name { name } else if let Some(parent) = flow_path.parent() { parent .file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) .unwrap_or_else(|| "unnamed".to_string()) } else { "unnamed".to_string() }; let env_space = cfg.env_space.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); let env_space_kind = parse_env_space_kind(cfg.env_space_kind.as_deref()); return Ok(EnvTargetConfig { project_name, env_space, env_space_kind, }); } let project_name = cwd .file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) .unwrap_or_else(|| "unnamed".to_string()); Ok(EnvTargetConfig { project_name, env_space: None, env_space_kind: EnvScope::Project, }) } fn resolve_env_target() -> Result<EnvTarget> { let cfg = load_env_target_config()?; Ok(match cfg.env_space_kind { EnvScope::Personal => EnvTarget::Personal { space: cfg.env_space, }, EnvScope::Project => EnvTarget::Project { name: cfg.env_space.unwrap_or(cfg.project_name), }, }) } fn resolve_personal_target() -> Result<EnvTarget> { let cfg = load_env_target_config()?; Ok(EnvTarget::Personal { space: cfg.env_space, }) } fn env_target_label(target: &EnvTarget) -> String { match target { EnvTarget::Project { name } => name.clone(), EnvTarget::Personal { space } => space.clone().unwrap_or_else(|| "personal".to_string()), } } fn normalize_env_backend_name(value: &str) -> Option<&'static str> { match value.trim().to_ascii_lowercase().as_str() { "local" => Some("local"), "cloud" | "remote" | "myflow" => Some("cloud"), _ => None, } } fn project_env_backend_from_config(cfg: &config::Config) -> Option<&'static str> { let mut resolved: Option<&'static str> = None; for source in [ cfg.host .as_ref() .and_then(|host| host.env_source.as_deref()), cfg.cloudflare .as_ref() .and_then(|cloudflare| cloudflare.env_source.as_deref()), cfg.web.as_ref().and_then(|web| web.env_source.as_deref()), ] { let Some(backend) = source.and_then(normalize_env_backend_name) else { continue; }; match resolved { Some(existing) if existing != backend => return None, Some(_) => {} None => resolved = Some(backend), } } resolved } fn project_env_backend_from_current_dir() -> Option<&'static str> { let cwd = std::env::current_dir().ok()?; let flow_path = find_flow_toml(&cwd)?; let cfg = config::load(&flow_path).ok()?; project_env_backend_from_config(&cfg) } fn local_env_enabled() -> bool { match std::env::var("FLOW_ENV_BACKEND") .ok() .as_deref() .and_then(normalize_env_backend_name) { Some("local") => return true, Some("cloud") => return false, _ => {} } if let Some(backend) = config::preferred_env_backend() { match normalize_env_backend_name(&backend) { Some("local") => return true, Some("cloud") => return false, _ => {} } } if let Some(backend) = project_env_backend_from_current_dir() { return backend == "local"; } std::env::var("FLOW_ENV_LOCAL") .ok() .map(|v| { let v = v.to_ascii_lowercase(); v == "1" || v == "true" || v == "yes" }) .unwrap_or(false) } fn local_env_root() -> Result<PathBuf> { let base = config::ensure_global_config_dir()?; let path = base.join(LOCAL_ENV_DIR); ensure_private_dir(&path)?; Ok(path) } fn ensure_private_dir(path: &Path) -> Result<()> { fs::create_dir_all(path)?; #[cfg(unix)] fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; Ok(()) } fn write_private_file(path: &Path, content: &str) -> Result<()> { if let Some(parent) = path.parent() { ensure_private_dir(parent)?; } #[cfg(unix)] { let mut file = OpenOptions::new() .create(true) .truncate(true) .write(true) .mode(0o600) .open(path)?; file.write_all(content.as_bytes())?; fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; } #[cfg(not(unix))] { fs::write(path, content)?; } Ok(()) } fn env_sealer_state_dir() -> Result<PathBuf> { let path = config::ensure_global_state_dir()?.join("env"); ensure_private_dir(&path)?; Ok(path) } fn env_sealer_identity_path() -> Result<PathBuf> { Ok(env_sealer_state_dir()?.join("sealer.json")) } fn load_env_sealer_identity() -> Result<Option<EnvSealerIdentity>> { let path = env_sealer_identity_path()?; if !path.exists() { return Ok(None); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let mut identity: EnvSealerIdentity = serde_json::from_str(&content).context("failed to parse env sealer identity")?; if identity.sealer_secret.trim().is_empty() { bail!("env sealer identity is missing its secret"); } let derived_id = get_sealer_id(&identity.sealer_secret).context("invalid env sealer secret")?; if identity.sealer_id != derived_id { identity.sealer_id = derived_id; let updated = serde_json::to_string_pretty(&identity)?; write_private_file(&path, &updated)?; } Ok(Some(identity)) } fn load_or_create_env_sealer_identity() -> Result<EnvSealerIdentity> { if let Some(identity) = load_env_sealer_identity()? { return Ok(identity); } let identity = create_env_sealer_identity()?; let path = env_sealer_identity_path()?; let content = serde_json::to_string_pretty(&identity)?; write_private_file(&path, &content)?; Ok(identity) } fn create_env_sealer_identity() -> Result<EnvSealerIdentity> { let private_key = new_x25519_private_key(); let sealer_secret = format!( "{}{}", ENV_SEALER_SECRET_PREFIX, bs58::encode(&private_key).into_string() ); let sealer_id = get_sealer_id(&sealer_secret).context("failed to derive env sealer id")?; Ok(EnvSealerIdentity { sealer_secret, sealer_id, }) } fn default_env_sealer_label() -> Option<String> { let host = std::env::var("FLOW_ENV_SEALER_LABEL") .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| std::env::var("HOSTNAME").ok()) .or_else(|| std::env::var("COMPUTERNAME").ok()); let user = std::env::var("USER") .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| std::env::var("USERNAME").ok()); match (host, user) { (Some(host), Some(user)) => Some(format!("{} ({})", host.trim(), user.trim())), (Some(host), None) => Some(host.trim().to_string()), (None, Some(user)) => Some(format!("Flow ({})", user.trim())), (None, None) => None, } } fn seal_project_env_value( value: &str, identity: &EnvSealerIdentity, recipient_ids: &[String], ) -> Result<SealedEnvWriteItem> { let mut content_key = [0u8; 32]; SysRng .try_fill_bytes(&mut content_key) .context("failed to generate env content key")?; let mut content_nonce = [0u8; 24]; SysRng .try_fill_bytes(&mut content_nonce) .context("failed to generate env content nonce")?; let cipher = XSalsa20Poly1305::new(&content_key.into()); let ciphertext = cipher .encrypt(&content_nonce.into(), value.as_bytes()) .map_err(|_| anyhow::anyhow!("failed to encrypt env value"))?; let mut recipients = Vec::new(); let mut seen = HashSet::new(); for recipient_id in recipient_ids { if !seen.insert(recipient_id.clone()) { continue; } let mut nonce_material = [0u8; 32]; SysRng .try_fill_bytes(&mut nonce_material) .context("failed to generate env recipient nonce")?; let wrapped_key = seal( &content_key, &identity.sealer_secret, recipient_id, &nonce_material, ) .context("failed to wrap env content key")?; recipients.push(SealedEnvWriteRecipient { recipient_id: recipient_id.clone(), recipient_kind: "sealer".to_string(), sender_id: identity.sealer_id.clone(), wrapped_key_b64: STANDARD.encode(&wrapped_key), nonce_material_b64: STANDARD.encode(nonce_material), }); } Ok(SealedEnvWriteItem { description: None, classification: "secret".to_string(), content: SealedEnvWriteContent { algorithm: SEALED_ENV_ALGORITHM.to_string(), ciphertext_b64: STANDARD.encode(ciphertext), nonce_b64: STANDARD.encode(content_nonce), }, recipients, }) } fn decrypt_project_env_value( item: &SealedEnvItem, identity: &EnvSealerIdentity, ) -> Result<Option<String>> { let content = item .content .as_ref() .ok_or_else(|| anyhow::anyhow!("sealed env item is missing content"))?; if content.algorithm != SEALED_ENV_ALGORITHM { bail!("unsupported sealed env algorithm: {}", content.algorithm); } let grant = match item .recipients .iter() .find(|grant| grant.recipient_id == identity.sealer_id) { Some(grant) => grant, None => return Ok(None), }; let sender_id = grant .sender_id .as_deref() .ok_or_else(|| anyhow::anyhow!("sealed env grant is missing sender id"))?; let wrapped_key = STANDARD .decode(grant.wrapped_key_b64.as_bytes()) .context("failed to decode sealed env wrapped key")?; let nonce_material = STANDARD .decode(grant.nonce_material_b64.as_bytes()) .context("failed to decode sealed env nonce material")?; let content_key = unseal( &wrapped_key, &identity.sealer_secret, sender_id, &nonce_material, ) .context("failed to unwrap env content key")?; let content_key: [u8; 32] = content_key .as_slice() .try_into() .map_err(|_| anyhow::anyhow!("invalid env content key length"))?; let ciphertext = STANDARD .decode(content.ciphertext_b64.as_bytes()) .context("failed to decode sealed env ciphertext")?; let nonce = STANDARD .decode(content.nonce_b64.as_bytes()) .context("failed to decode sealed env nonce")?; let nonce: [u8; 24] = nonce .as_slice() .try_into() .map_err(|_| anyhow::anyhow!("invalid env content nonce length"))?; let cipher = XSalsa20Poly1305::new(&content_key.into()); let plaintext = cipher .decrypt(&nonce.into(), ciphertext.as_ref()) .map_err(|_| anyhow::anyhow!("failed to decrypt sealed env value"))?; let value = String::from_utf8(plaintext).context("sealed env value is not valid UTF-8")?; Ok(Some(value)) } fn sanitize_env_segment(value: &str) -> String { let mut out = String::new(); let mut last_sep = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { out.push(ch); last_sep = false; } else if !last_sep { out.push('_'); last_sep = true; } } let trimmed = out.trim_matches('_').to_string(); if trimmed.is_empty() { "unnamed".to_string() } else { trimmed } } fn local_env_path(target: &EnvTarget, environment: &str) -> Result<PathBuf> { let root = local_env_root()?; let target_label = sanitize_env_segment(&env_target_label(target)); let env_label = sanitize_env_segment(if environment.trim().is_empty() { "production" } else { environment }); let dir = root.join(target_label); ensure_private_dir(&dir)?; Ok(dir.join(format!("{env_label}.env"))) } fn local_personal_keychain_supported(target: &EnvTarget) -> bool { cfg!(target_os = "macos") && matches!(target, EnvTarget::Personal { .. }) } fn local_personal_keychain_write_enabled(target: &EnvTarget) -> bool { if !local_personal_keychain_supported(target) { return false; } !std::env::var("FLOW_ENV_LOCAL_PLAINTEXT") .ok() .map(|value| { let value = value.to_ascii_lowercase(); value == "1" || value == "true" || value == "yes" }) .unwrap_or(false) } fn local_keychain_ref(key: &str) -> String { format!("{LOCAL_KEYCHAIN_REF_PREFIX}{key}") } fn is_local_keychain_ref(value: &str) -> bool { value.starts_with(LOCAL_KEYCHAIN_REF_PREFIX) } fn read_local_env_vars_raw( target: &EnvTarget, environment: &str, ) -> Result<HashMap<String, String>> { let path = local_env_path(target, environment)?; if !path.exists() { return Ok(HashMap::new()); } let content = fs::read_to_string(&path)?; Ok(parse_env_file(&content)) } fn migrate_local_env_vars_to_keychain_if_needed( target: &EnvTarget, environment: &str, vars: &mut HashMap<String, String>, ) -> Result<()> { if !local_personal_keychain_write_enabled(target) { return Ok(()); } let mut changed = false; let snapshot = vars.clone(); for (key, value) in snapshot { if is_local_keychain_ref(&value) { continue; } set_local_keychain_env_var(target, environment, &key, &value)?; vars.insert(key.clone(), local_keychain_ref(&key)); changed = true; } if changed { write_local_env_vars_raw(target, environment, vars)?; } Ok(()) } fn resolve_local_env_vars( target: &EnvTarget, environment: &str, vars: &HashMap<String, String>, ) -> Result<HashMap<String, String>> { if !local_personal_keychain_supported(target) || !vars.values().any(|value| is_local_keychain_ref(value)) { return Ok(vars.clone()); } require_env_read_unlock()?; let mut resolved = HashMap::new(); for (key, value) in vars { if is_local_keychain_ref(value) { let secret = get_local_keychain_env_var(target, environment, key)?.ok_or_else(|| { anyhow::anyhow!( "local env var '{}' is referenced from Keychain but the Keychain entry is missing", key ) })?; resolved.insert(key.clone(), secret); } else { resolved.insert(key.clone(), value.clone()); } } Ok(resolved) } fn read_local_env_vars(target: &EnvTarget, environment: &str) -> Result<HashMap<String, String>> { let mut vars = read_local_env_vars_raw(target, environment)?; migrate_local_env_vars_to_keychain_if_needed(target, environment, &mut vars)?; resolve_local_env_vars(target, environment, &vars) } /// Returns true if the user has a cloud auth token configured. pub fn has_cloud_auth_token() -> bool { load_auth_config().ok().and_then(|a| a.token).is_some() } /// Read keys from the local personal env store without cloud access. pub fn fetch_local_personal_env_vars(keys: &[String]) -> Result<HashMap<String, String>> { let target = resolve_personal_target()?; let vars = read_local_env_vars(&target, "production")?; if keys.is_empty() { return Ok(vars); } let mut filtered = HashMap::new(); let mut missing = Vec::new(); for key in keys { if let Some(value) = vars.get(key) { filtered.insert(key.clone(), value.clone()); } else { missing.push(key.clone()); } } if !missing.is_empty() && local_personal_keychain_supported(&target) { require_env_read_unlock()?; for key in missing { if let Some(value) = get_local_keychain_env_var(&target, "production", &key)? { filtered.insert(key, value); } } } Ok(filtered) } fn write_local_env_vars_raw( target: &EnvTarget, environment: &str, vars: &HashMap<String, String>, ) -> Result<PathBuf> { let path = local_env_path(target, environment)?; let mut keys: Vec<_> = vars.keys().collect(); keys.sort(); let mut content = String::new(); content.push_str(&format!( "# Local env store (flow)\n# Target: {}\n# Environment: {}\n", env_target_label(target), environment )); for key in keys { let value = &vars[key]; let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{key}=\"{escaped}\"\n")); } write_private_file(&path, &content)?; Ok(path) } fn set_local_env_var( target: &EnvTarget, environment: &str, key: &str, value: &str, ) -> Result<PathBuf> { let mut vars = read_local_env_vars_raw(target, environment)?; if local_personal_keychain_write_enabled(target) { migrate_local_env_vars_to_keychain_if_needed(target, environment, &mut vars)?; set_local_keychain_env_var(target, environment, key, value)?; vars.insert(key.to_string(), local_keychain_ref(key)); } else { vars.insert(key.to_string(), value.to_string()); } write_local_env_vars_raw(target, environment, &vars) } fn delete_local_env_vars( target: &EnvTarget, environment: &str, keys: &[String], ) -> Result<PathBuf> { if keys.is_empty() { bail!("No keys specified"); } let mut vars = read_local_env_vars_raw(target, environment)?; for key in keys { vars.remove(key); if local_personal_keychain_supported(target) { delete_local_keychain_env_var(target, environment, key)?; } } migrate_local_env_vars_to_keychain_if_needed(target, environment, &mut vars)?; write_local_env_vars_raw(target, environment, &vars) } fn is_local_fallback_error(err: &anyhow::Error) -> bool { let msg = err.to_string().to_ascii_lowercase(); msg.contains("not logged in") || msg.contains("failed to connect to cloud") || msg.contains("unauthorized") } fn env_target_name_for_tokens(target: &EnvTarget) -> Result<String> { match target { EnvTarget::Project { name } => Ok(name.clone()), EnvTarget::Personal { space } => space.clone().ok_or_else(|| { anyhow::anyhow!( "Personal env space name required for service tokens. Set env_space in flow.toml." ) }), } } fn resolve_env_file_path() -> Result<PathBuf> { let cwd = std::env::current_dir()?; if let Some(flow_path) = find_flow_toml(&cwd) { let project_root = flow_path.parent().unwrap_or(&cwd); let cfg = config::load(&flow_path)?; if let Some(cf_cfg) = cfg.cloudflare { if let Some(env_file) = cf_cfg.env_file { let env_file = env_file.trim(); if !env_file.is_empty() { let expanded = config::expand_path(env_file); return Ok(project_root.join(expanded)); } } } return Ok(project_root.join(".env")); } Ok(cwd.join(".env")) } /// Get API URL from config or default. fn get_api_url(auth: &AuthConfig) -> String { auth.api_url .clone() .unwrap_or_else(|| DEFAULT_API_URL.to_string()) } fn get_ai_api_url(auth: &AuthConfig) -> String { auth.ai_api_url .clone() .unwrap_or_else(|| DEFAULT_API_URL.to_string()) } pub fn load_ai_auth_token() -> Result<Option<String>> { let auth = load_ai_auth_config()?; Ok(auth.ai_token) } pub fn load_ai_api_url() -> Result<String> { let auth = load_auth_config_raw()?; Ok(get_ai_api_url(&auth)) } /// Load the base URL for the cloud env API (defaults to https://myflow.sh). /// This is used for host deploy integrations where the remote host needs to fetch envs. pub fn load_env_api_url() -> Result<String> { let auth = load_auth_config_raw()?; Ok(get_api_url(&auth)) } pub fn save_ai_auth_token(token: String, api_url: Option<String>) -> Result<()> { let mut auth = load_auth_config_raw()?; if let Some(api_url) = api_url { auth.ai_api_url = Some(api_url); } store_ai_auth_token(&mut auth, token)?; save_auth_config(&auth)?; Ok(()) } fn find_flow_toml(start: &PathBuf) -> Option<PathBuf> { let mut current = start.clone(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } fn is_cloud_source(source: Option<&str>) -> bool { matches!( source.map(|s| s.to_ascii_lowercase()).as_deref(), Some("cloud") | Some("remote") | Some("myflow") ) } fn project_plaintext_cloud_mirror_required_for_config(cfg: &config::Config) -> bool { cfg.host .as_ref() .map(|host| { is_cloud_source(host.env_source.as_deref()) && host .service_token .as_deref() .map(|value| !value.trim().is_empty()) .unwrap_or(false) }) .unwrap_or(false) } fn project_plaintext_cloud_mirror_required() -> bool { if std::env::var("FLOW_ENV_CLOUD_PLAINTEXT_MIRROR") .ok() .map(|value| { let normalized = value.trim().to_ascii_lowercase(); normalized == "1" || normalized == "true" || normalized == "yes" }) .unwrap_or(false) { return true; } let Ok(cwd) = std::env::current_dir() else { return false; }; let Some(flow_path) = find_flow_toml(&cwd) else { return false; }; let Ok(cfg) = config::load(&flow_path) else { return false; }; project_plaintext_cloud_mirror_required_for_config(&cfg) } fn select_requested_env_keys( vars: HashMap<String, String>, keys: &[String], ) -> HashMap<String, String> { if keys.is_empty() { return vars; } let requested: HashSet<_> = keys.iter().cloned().collect(); vars.into_iter() .filter(|(key, _)| requested.contains(key)) .collect() } fn ensure_cloud_env_sealer_registered( api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<EnvSealerIdentity> { let identity = load_or_create_env_sealer_identity()?; let url = Url::parse(&format!("{}/api/env/sealers/self", api_url))?; let mut body = serde_json::json!({ "sealerId": identity.sealer_id, }); if let Some(label) = default_env_sealer_label() { body["label"] = serde_json::json!(label); } let resp = client .post(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 404 { return Ok(identity); } if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } Ok(identity) } fn fetch_project_member_sealer_ids( project_name: &str, self_sealer_id: &str, api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<Vec<String>> { let url = Url::parse(&format!( "{}/api/env/projects/{}/sealers", api_url, project_name ))?; let resp = client .get(url) .header("Authorization", format!("Bearer {}", token)) .send() .context("failed to connect to cloud")?; if resp.status() == 404 { return Ok(vec![self_sealer_id.to_string()]); } if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let data: ProjectSealersResponse = resp.json().context("failed to parse response")?; let mut recipient_ids = Vec::new(); let mut seen = HashSet::new(); for member in data.members { for sealer in member.sealers { if seen.insert(sealer.sealer_id.clone()) { recipient_ids.push(sealer.sealer_id); } } } if seen.insert(self_sealer_id.to_string()) { recipient_ids.push(self_sealer_id.to_string()); } Ok(recipient_ids) } fn fetch_sealed_project_env_payload( project_name: &str, environment: &str, keys: &[String], api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<Option<SealedEnvResponse>> { let mut url = Url::parse(&format!("{}/api/env/sealed/{}", api_url, project_name))?; url.query_pairs_mut() .append_pair("environment", environment); if !keys.is_empty() { url.query_pairs_mut().append_pair("keys", &keys.join(",")); } let resp = client .get(url) .header("Authorization", format!("Bearer {}", token)) .send() .context("failed to connect to cloud")?; if resp.status() == 404 { return Ok(None); } if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let data: SealedEnvResponse = resp.json().context("failed to parse response")?; Ok(Some(data)) } fn fetch_legacy_project_cloud_entries( project_name: &str, environment: &str, keys: &[String], api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<ProjectCloudEnvEntries> { let mut url = Url::parse(&format!("{}/api/env/{}", api_url, project_name))?; url.query_pairs_mut() .append_pair("environment", environment); if !keys.is_empty() { url.query_pairs_mut().append_pair("keys", &keys.join(",")); } let resp = client .get(url) .header("Authorization", format!("Bearer {}", token)) .send() .context("failed to connect to cloud")?; if resp.status() == 404 { return Ok(ProjectCloudEnvEntries::default()); } if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let data: EnvResponse = resp.json().context("failed to parse response")?; Ok(ProjectCloudEnvEntries { vars: data.env, descriptions: data.descriptions, }) } fn write_legacy_project_cloud_entries( project_name: &str, environment: &str, vars: &HashMap<String, String>, descriptions: &HashMap<String, String>, api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<()> { if vars.is_empty() { return Ok(()); } let url = Url::parse(&format!("{}/api/env/{}", api_url, project_name))?; let mut body = serde_json::json!({ "vars": vars, "environment": environment, }); if !descriptions.is_empty() { body["descriptions"] = serde_json::json!(descriptions); } let resp = client .post(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let _: SetEnvResponse = resp.json().context("failed to parse response")?; Ok(()) } fn delete_legacy_project_cloud_entries( project_name: &str, environment: &str, keys: &[String], api_url: &str, token: &str, client: &reqwest::blocking::Client, ignore_missing_project: bool, ) -> Result<()> { if keys.is_empty() { return Ok(()); } let url = Url::parse(&format!("{}/api/env/{}", api_url, project_name))?; let body = serde_json::json!({ "keys": keys, "environment": environment, }); let resp = client .delete(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 404 && ignore_missing_project { return Ok(()); } if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } Ok(()) } fn fetch_project_cloud_env_entries( project_name: &str, environment: &str, keys: &[String], api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<ProjectCloudEnvEntries> { let identity = ensure_cloud_env_sealer_registered(api_url, token, client)?; let sealed = fetch_sealed_project_env_payload(project_name, environment, keys, api_url, token, client)?; let mut entries = ProjectCloudEnvEntries::default(); let mut inaccessible_keys = Vec::new(); if let Some(sealed) = sealed { for (key, item) in sealed.items { if item.content.is_none() { continue; } match decrypt_project_env_value(&item, &identity)? { Some(value) => { entries.vars.insert(key.clone(), value); if let Some(description) = item.description { entries.descriptions.insert(key, description); } } None => { if item.available_recipient_count > 0 || !item.recipients.is_empty() { inaccessible_keys.push(key); } } } } } let legacy_keys: Vec<String> = if keys.is_empty() { Vec::new() } else { keys.iter() .filter(|key| !entries.vars.contains_key(key.as_str())) .cloned() .collect() }; let legacy = fetch_legacy_project_cloud_entries( project_name, environment, &legacy_keys, api_url, token, client, )?; for (key, value) in legacy.vars { entries.vars.entry(key).or_insert(value); } for (key, description) in legacy.descriptions { entries.descriptions.entry(key).or_insert(description); } inaccessible_keys.retain(|key| !entries.vars.contains_key(key)); inaccessible_keys.sort(); inaccessible_keys.dedup(); if !inaccessible_keys.is_empty() { bail!( "Some project env vars exist but are not shared with this device: {}. Re-save them from a device with access, or register this device's sealer and re-share them.", inaccessible_keys.join(", ") ); } Ok(entries) } fn write_project_cloud_env_entries( project_name: &str, environment: &str, vars: &HashMap<String, String>, descriptions: &HashMap<String, String>, api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<bool> { if vars.is_empty() { return Ok(false); } let identity = ensure_cloud_env_sealer_registered(api_url, token, client)?; let recipient_ids = fetch_project_member_sealer_ids(project_name, &identity.sealer_id, api_url, token, client)?; let mut items = HashMap::new(); for (key, value) in vars { let mut item = seal_project_env_value(value, &identity, &recipient_ids)?; item.description = descriptions.get(key).cloned(); items.insert(key.clone(), item); } let url = Url::parse(&format!("{}/api/env/sealed/{}", api_url, project_name))?; let body = SealedEnvWriteRequest { environment: environment.to_string(), items, }; let resp = client .post(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 404 { bail!( "The cloud env server does not support sealed project env storage yet. Deploy the updated MyFlow env API before writing project envs." ); } if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let mirror_plaintext = project_plaintext_cloud_mirror_required(); if mirror_plaintext { write_legacy_project_cloud_entries( project_name, environment, vars, descriptions, api_url, token, client, ) .context("sealed write succeeded, but writing the required plaintext host mirror failed")?; } else { let keys: Vec<String> = vars.keys().cloned().collect(); delete_legacy_project_cloud_entries( project_name, environment, &keys, api_url, token, client, true, ) .context("sealed write succeeded, but removing the legacy plaintext mirror failed")?; } Ok(mirror_plaintext) } fn delete_project_cloud_env_entries( project_name: &str, environment: &str, keys: &[String], api_url: &str, token: &str, client: &reqwest::blocking::Client, ) -> Result<()> { if keys.is_empty() { return Ok(()); } let url = Url::parse(&format!("{}/api/env/sealed/{}", api_url, project_name))?; let body = serde_json::json!({ "keys": keys, "environment": environment, }); let resp = client .delete(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; match resp.status() { status if status == 404 => {} status if status == 401 => bail!("Unauthorized. Check your token with `f env login`."), status if !status.is_success() => { let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } _ => {} } delete_legacy_project_cloud_entries( project_name, environment, keys, api_url, token, client, true, ) } fn format_default_hint(value: &str) -> String { value.to_string() } pub fn get_personal_env_var(key: &str) -> Result<Option<String>> { if local_env_enabled() { let vars = fetch_local_personal_env_vars(&[key.to_string()])?; return Ok(vars.get(key).cloned()); } let auth = load_auth_config()?; let token = match auth.token.as_ref() { Some(t) => t, None => return Ok(None), }; require_env_read_unlock()?; let api_url = get_api_url(&auth); let target = resolve_personal_target()?; let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; url.query_pairs_mut().append_pair("keys", key); if let EnvTarget::Personal { ref space } = target { if let Some(space) = space.as_ref() { url.query_pairs_mut().append_pair("space", &space); } } let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(15))?; let resp = client .get(url) .header("Authorization", format!("Bearer {}", token)) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { return Ok(None); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let data: PersonalEnvResponse = resp.json().context("failed to parse response")?; Ok(data.env.get(key).cloned()) } /// Fuzzy search personal env vars and copy selected value to clipboard. fn fuzzy_select_env() -> Result<()> { require_env_read_unlock()?; // Fetch all personal env vars let target = resolve_personal_target()?; let vars = fetch_env_vars(&target, "production", &[], false)?; if vars.is_empty() { println!("No personal env vars found."); println!("Set one with: f env set KEY=VALUE"); return Ok(()); } // Format for fzf: KEY=VALUE (showing first 40 chars of value) let mut lines: Vec<String> = vars .iter() .map(|(k, v)| { let preview = if v.len() > 40 { format!("{}...", &v[..40]) } else { v.clone() }; format!("{}\t{}", k, preview) }) .collect(); lines.sort(); let input = lines.join("\n"); // Run fzf let mut child = Command::new("fzf") .args([ "--height=40%", "--reverse", "--delimiter=\t", "--with-nth=1", ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() .context("Failed to run fzf. Is it installed?")?; if let Some(stdin) = child.stdin.as_mut() { use std::io::Write; stdin.write_all(input.as_bytes())?; } let output = child.wait_with_output()?; if !output.status.success() { // User cancelled return Ok(()); } let selected = String::from_utf8_lossy(&output.stdout); let selected = selected.trim(); if selected.is_empty() { return Ok(()); } // Extract key from selection let key = selected.split('\t').next().unwrap_or(selected); // Get the full value if let Some(value) = vars.get(key) { if std::env::var("FLOW_NO_CLIPBOARD").is_ok() || !std::io::stdin().is_terminal() { println!("Clipboard disabled; skipping copy."); } else { // Copy to clipboard let mut pbcopy = Command::new("pbcopy") .stdin(std::process::Stdio::piped()) .spawn() .context("Failed to run pbcopy")?; if let Some(stdin) = pbcopy.stdin.as_mut() { use std::io::Write; stdin.write_all(value.as_bytes())?; } pbcopy.wait()?; println!("Copied {} to clipboard", key); } } Ok(()) } /// Run the env subcommand. pub fn run(action: Option<EnvAction>) -> Result<()> { // No action = fuzzy search personal envs and copy value let Some(action) = action else { let auth = load_auth_config()?; if auth.token.is_some() { return fuzzy_select_env(); } return status(); }; match action { EnvAction::Sync => agent_setup::run()?, EnvAction::Unlock => unlock_env_read()?, EnvAction::Login => login()?, EnvAction::New => new_env_template()?, EnvAction::Pull { environment } => pull(&environment)?, EnvAction::Push { environment } => push(&environment)?, EnvAction::Guide { environment } => guide(&environment)?, EnvAction::Apply => { let cwd = std::env::current_dir()?; let flow_path = find_flow_toml(&cwd) .ok_or_else(|| anyhow::anyhow!("flow.toml not found. Run `f init` first."))?; let project_root = flow_path.parent().map(|p| p.to_path_buf()).unwrap_or(cwd); let flow_config = config::load(&flow_path)?; deploy::apply_cloudflare_env(&project_root, Some(&flow_config))?; } EnvAction::Bootstrap => { let cwd = std::env::current_dir()?; let flow_path = find_flow_toml(&cwd) .ok_or_else(|| anyhow::anyhow!("flow.toml not found. Run `f init` first."))?; let project_root = flow_path.parent().map(|p| p.to_path_buf()).unwrap_or(cwd); let flow_config = config::load(&flow_path)?; bootstrap_cloudflare_secrets(&project_root, &flow_config)?; } EnvAction::Keys => { show_keys()?; } EnvAction::Setup { env_file, environment, } => setup(env_file, environment)?, EnvAction::List { environment } => list(&environment)?, EnvAction::Set { pair, personal } => { let _ = personal; set_personal_env_var_from_pair(&pair)?; } EnvAction::Delete { keys } => delete_personal_env_vars(&keys)?, EnvAction::Project { action } => run_project_env_action(action)?, EnvAction::Status => status()?, EnvAction::Get { keys, personal, environment, format, } => get_vars(&keys, personal, &environment, &format)?, EnvAction::Run { personal, environment, keys, command, } => run_with_env(personal, &environment, &keys, &command)?, EnvAction::Token { action } => run_token_action(action)?, } Ok(()) } fn run_token_action(action: TokenAction) -> Result<()> { match action { TokenAction::Create { name, permissions } => token_create(name.as_deref(), &permissions)?, TokenAction::List => token_list()?, TokenAction::Revoke { name } => token_revoke(&name)?, } Ok(()) } #[derive(Clone, Copy)] struct EnvTemplate { id: &'static str, title: &'static str, key: &'static str, description: &'static str, instructions: &'static [&'static str], } fn env_templates() -> Vec<EnvTemplate> { vec![EnvTemplate { id: "cloudflare", title: "Cloudflare API token", key: "CLOUDFLARE_API_TOKEN", description: "Token used by wrangler to deploy Workers/Pages.", instructions: &[ "Open https://dash.cloudflare.com/profile/api-tokens", "Create a token (Template: Edit Cloudflare Workers or Custom)", "Permissions: Workers Scripts:Edit, Workers Routes:Edit, Pages:Edit", "Add Zone:Read + DNS:Edit for your domain", "Copy the token value", ], }] } fn new_env_template() -> Result<()> { ensure_env_login()?; let templates = env_templates(); if templates.is_empty() { println!("No env templates available."); return Ok(()); } let Some(template) = select_env_template(&templates)? else { println!("No template selected."); return Ok(()); }; println!("Template: {}", template.title); println!("Key: {}", template.key); println!("{}", template.description); println!(); println!("How to get it:"); for step in template.instructions { println!(" - {}", step); } println!(); let label = format!("Enter {} token (input hidden): ", template.id); let value = prompt_secret(&label)?; let Some(value) = value else { println!("No token entered; nothing saved."); return Ok(()); }; set_personal_env_var(template.key, &value)?; println!(); println!("Saved {} to personal envs.", template.key); Ok(()) } fn select_env_template(templates: &[EnvTemplate]) -> Result<Option<EnvTemplate>> { if templates.is_empty() { return Ok(None); } let use_fzf = std::io::stdin().is_terminal() && which("fzf").is_ok(); if use_fzf { let mut lines = Vec::new(); for template in templates { lines.push(format!("{}\t{}", template.id, template.title)); } let input = lines.join("\n"); let mut child = Command::new("fzf") .args([ "--height=40%", "--reverse", "--delimiter=\t", "--with-nth=1,2", ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() .context("Failed to run fzf. Is it installed?")?; if let Some(stdin) = child.stdin.as_mut() { use std::io::Write; stdin.write_all(input.as_bytes())?; } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selected = String::from_utf8_lossy(&output.stdout); let selected = selected.trim(); if selected.is_empty() { return Ok(None); } let id = selected.split('\t').next().unwrap_or(selected); return Ok(templates.iter().copied().find(|t| t.id == id)); } println!("Available templates:"); for (idx, template) in templates.iter().enumerate() { println!(" {}. {} ({})", idx + 1, template.title, template.key); } println!(); let selection = prompt_line("Select a template number (blank to cancel): ")?; let Some(selection) = selection else { return Ok(None); }; let idx: usize = selection.trim().parse().context("Invalid selection")?; if idx == 0 || idx > templates.len() { bail!("Selection out of range"); } Ok(Some(templates[idx - 1])) } fn ensure_env_login() -> Result<()> { let auth = load_auth_config()?; if auth.token.is_some() { return Ok(()); } if !std::io::stdin().is_terminal() { bail!("Not logged in. Run `f env login` first."); } if prompt_confirm("Not logged in. Run `f env login` now? (y/N): ")? { login()?; return Ok(()); } bail!("Not logged in. Run `f env login` first."); } fn run_project_env_action(action: ProjectEnvAction) -> Result<()> { match action { ProjectEnvAction::Set { pair, environment } => { set_project_env_var_from_pair(&pair, &environment)? } ProjectEnvAction::Delete { keys, environment } => { delete_project_env_vars(&keys, &environment)? } ProjectEnvAction::List { environment } => list(&environment)?, } Ok(()) } fn set_personal_env_var_from_pair(pair: &str) -> Result<()> { let (key, value) = pair .split_once('=') .ok_or_else(|| anyhow::anyhow!("Invalid format. Use KEY=VALUE"))?; set_personal_env_var(key.trim(), value.trim()) } fn set_project_env_var_from_pair(pair: &str, environment: &str) -> Result<()> { let (key, value) = pair .split_once('=') .ok_or_else(|| anyhow::anyhow!("Invalid format. Use KEY=VALUE"))?; set_project_env_var_internal(key.trim(), value.trim(), environment, None) } pub(crate) fn delete_personal_env_vars(keys: &[String]) -> Result<()> { if local_env_enabled() { let target = resolve_personal_target()?; let path = delete_local_env_vars(&target, "production", keys)?; println!( "✓ Deleted {} key(s) from personal envs (stored at {})", keys.len(), path.display() ); return Ok(()); } let auth = load_auth_config()?; if keys.is_empty() { bail!("No keys specified"); } let target = resolve_personal_target()?; let token = match auth.token.as_ref() { Some(token) => token, None => { let path = delete_local_env_vars(&target, "production", keys)?; println!( "✓ Deleted {} key(s) from personal envs (stored at {})", keys.len(), path.display() ); return Ok(()); } }; let api_url = get_api_url(&auth); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; if let EnvTarget::Personal { ref space } = target { if let Some(space) = space.as_ref() { url.query_pairs_mut().append_pair("space", space); } } let body = serde_json::json!({ "keys": keys }); let resp = client .delete(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } println!("✓ Deleted {} key(s)", keys.len()); Ok(()) } fn delete_project_env_vars(keys: &[String], environment: &str) -> Result<()> { if local_env_enabled() { let target = resolve_env_target()?; let target_label = env_target_label(&target); let path = delete_local_env_vars(&target, environment, keys)?; println!( "✓ Deleted {} key(s) from {} ({}) locally at {}", keys.len(), target_label, environment, path.display() ); return Ok(()); } let auth = load_auth_config()?; let token = auth .token .as_ref() .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `f env login` first."))?; if keys.is_empty() { bail!("No keys specified"); } let api_url = get_api_url(&auth); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let target = resolve_env_target()?; match &target { EnvTarget::Personal { space } => { let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; if let Some(space) = space { url.query_pairs_mut().append_pair("space", space); } let body = serde_json::json!({ "keys": keys, "environment": environment, }); let resp = client .delete(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } } EnvTarget::Project { name } => { delete_project_cloud_env_entries(name, environment, keys, &api_url, token, &client)?; } } let target_label = match target { EnvTarget::Personal { space } => { format!( "personal{}", space.map(|s| format!(":{}", s)).unwrap_or_default() ) } EnvTarget::Project { name } => name, }; println!( "✓ Deleted {} key(s) from {} ({})", keys.len(), target_label, environment ); Ok(()) } /// Login / set token. fn login() -> Result<()> { let mut auth = load_auth_config_raw()?; println!("Cloud Environment Manager"); println!("─────────────────────────────"); println!(); println!("To get a token:"); println!(" 1. Go to {} and sign in", DEFAULT_API_URL); println!(" 2. Go to Settings → API Tokens"); println!(" 3. Create a new token"); println!(); let api_url = prompt_line_default("API base URL", Some(DEFAULT_API_URL))?; if let Some(api_url) = api_url { auth.api_url = Some(api_url); } print!("Enter your API token: "); io::stdout().flush()?; let mut token = String::new(); io::stdin().read_line(&mut token)?; let token = token.trim().to_string(); if token.is_empty() { bail!("Token cannot be empty"); } if !token.starts_with("cloud_") && !token.starts_with("flow_") { println!( "Warning: Token doesn't start with 'cloud_' or 'flow_' - are you sure this is correct?" ); } store_auth_token(&mut auth, token)?; save_auth_config(&auth)?; println!(); if auth.token_source.as_deref() == Some("keychain") { println!("✓ Token saved to Keychain"); } else { println!("✓ Token saved to {}", get_auth_config_path().display()); } println!(); println!("You can now use:"); println!(" f env pull - Fetch env vars for this project"); println!(" f env push - Push local .env to cloud"); println!(" f env list - List env vars"); Ok(()) } /// Pull env vars from cloud and write to .env. fn pull(environment: &str) -> Result<()> { let target = resolve_env_target()?; let label = env_target_label(&target); println!("Fetching envs for '{}' ({})...", label, environment); let vars = fetch_env_vars(&target, environment, &[], true)?; if vars.is_empty() { println!("No env vars found for '{}' ({})", label, environment); return Ok(()); } // Write to .env let mut content = String::new(); content.push_str(&format!( "# Environment: {} (pulled from cloud)\n", environment )); content.push_str(&format!("# Space: {}\n", label)); content.push_str("#\n"); let mut keys: Vec<_> = vars.keys().collect(); keys.sort(); for key in keys { let value = &vars[key]; // Escape quotes in value let escaped = value.replace('\"', "\\\""); content.push_str(&format!("{}=\"{}\"\n", key, escaped)); } let env_path = resolve_env_file_path()?; if let Some(parent) = env_path.parent() { fs::create_dir_all(parent)?; } fs::write(&env_path, &content)?; println!("✓ Wrote {} env vars to {}", vars.len(), env_path.display()); Ok(()) } /// Push local .env to cloud. fn push(environment: &str) -> Result<()> { let env_path = resolve_env_file_path()?; if !env_path.exists() { bail!("env file not found: {}", env_path.display()); } let content = fs::read_to_string(&env_path)?; let vars = parse_env_file(&content); if vars.is_empty() { println!("No env vars found in .env"); return Ok(()); } push_vars(environment, vars) } fn push_vars(environment: &str, vars: HashMap<String, String>) -> Result<()> { if vars.is_empty() { println!("No env vars selected."); return Ok(()); } let auth = load_auth_config()?; let token = auth .token .as_ref() .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `f env login` first."))?; let api_url = get_api_url(&auth); let target = resolve_env_target()?; let label = env_target_label(&target); println!( "Pushing {} env vars to '{}' ({})...", vars.len(), label, environment ); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let mirrored_plaintext = match &target { EnvTarget::Personal { space } => { let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; if let Some(space) = space { url.query_pairs_mut().append_pair("space", space); } let body = serde_json::json!({ "vars": &vars, "environment": environment, }); let resp = client .post(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } false } EnvTarget::Project { name } => write_project_cloud_env_entries( name, environment, &vars, &HashMap::new(), &api_url, token, &client, )?, }; if mirrored_plaintext { println!( "✓ Pushed {} env vars to cloud (sealed, plus required plaintext host mirror)", vars.len() ); } else if matches!(target, EnvTarget::Project { .. }) { println!("✓ Pushed {} env vars to cloud (sealed)", vars.len()); } else { println!("✓ Pushed {} env vars to cloud", vars.len()); } Ok(()) } fn guide(environment: &str) -> Result<()> { let cwd = std::env::current_dir()?; let flow_path = find_flow_toml(&cwd) .ok_or_else(|| anyhow::anyhow!("flow.toml not found. Run `f init` first."))?; let cfg = config::load(&flow_path)?; let cf_cfg = cfg .cloudflare .as_ref() .context("No [cloudflare] section in flow.toml")?; let mut required = Vec::new(); let mut seen = HashSet::new(); for key in cf_cfg.env_keys.iter().chain(cf_cfg.env_vars.iter()) { if seen.insert(key.clone()) { required.push(key.clone()); } } if required.is_empty() { bail!( "No env keys configured. Add cloudflare.env_keys or cloudflare.env_vars to flow.toml." ); } println!("Checking required env vars for '{}'...", environment); let existing = match fetch_project_env_vars(environment, &required) { Ok(vars) => vars, Err(err) => { let msg = format!("{err:#}"); if msg.contains("Project not found.") { println!(" (project not found yet; will create on first set)"); HashMap::new() } else { return Err(err); } } }; let var_keys: HashSet<String> = cf_cfg.env_vars.iter().cloned().collect(); let mut missing = Vec::new(); for key in &required { if existing .get(key) .map(|v| !v.trim().is_empty()) .unwrap_or(false) { println!(" ✓ {}", key); } else { println!(" ✗ {} (missing)", key); missing.push(key.clone()); } } if missing.is_empty() { println!("✓ All required env vars are set."); return Ok(()); } println!(); println!("Enter missing values (leave empty to skip)."); for key in missing { let default_value = cf_cfg.env_defaults.get(&key).map(|value| value.as_str()); let is_secret = !var_keys.contains(&key); let value = prompt_value(&key, default_value, is_secret)?; if let Some(value) = value { set_project_env_var(&key, &value, environment, None)?; } } Ok(()) } fn bootstrap_cloudflare_secrets(project_root: &Path, cfg: &config::Config) -> Result<()> { let cf_cfg = cfg .cloudflare .as_ref() .context("No [cloudflare] section in flow.toml")?; if cf_cfg.bootstrap_secrets.is_empty() { bail!("No bootstrap secrets configured. Add cloudflare.bootstrap_secrets to flow.toml."); } println!("Bootstrap Cloudflare secrets"); println!("─────────────────────────────"); println!("Enter values (leave empty to skip)."); let mut values = HashMap::new(); let mut generated_env_token: Option<String> = None; let needs_env_account = cf_cfg.bootstrap_secrets.iter().any(|key| { key == "JAZZ_APP_ID" || key == "JAZZ_BACKEND_SECRET" || key == "JAZZ_ADMIN_SECRET" || key == "JAZZ_WORKER_ACCOUNT" || key == "JAZZ_WORKER_SECRET" }); let needs_auth_account = cf_cfg.bootstrap_secrets.iter().any(|key| { key == "JAZZ_AUTH_APP_ID" || key == "JAZZ_AUTH_BACKEND_SECRET" || key == "JAZZ_AUTH_ADMIN_SECRET" || key == "JAZZ_AUTH_WORKER_ACCOUNT_ID" || key == "JAZZ_AUTH_WORKER_ACCOUNT_SECRET" }); if needs_env_account || needs_auth_account { let project = storage_project_name()?; let default_env_name = format!("{}-jazz2-env", sanitize_name(&project)); let default_auth_name = format!("{}-jazz2-auth", sanitize_name(&project)); let default_server = "https://cloud.jazz.tools"; if needs_env_account { if prompt_confirm("Generate new Jazz2 env-store app credentials now? (y/N): ")? { println!("Creating Jazz2 env-store app credentials..."); let name = cf_cfg .bootstrap_jazz_name .as_deref() .unwrap_or(&default_env_name); let server = cf_cfg .bootstrap_jazz_peer .as_deref() .unwrap_or(default_server); let creds = create_jazz_app_credentials(name)?; if cf_cfg.bootstrap_secrets.iter().any(|k| k == "JAZZ_APP_ID") { values.insert("JAZZ_APP_ID".to_string(), creds.app_id.clone()); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_BACKEND_SECRET") { values.insert( "JAZZ_BACKEND_SECRET".to_string(), creds.backend_secret.clone(), ); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_ADMIN_SECRET") { values.insert("JAZZ_ADMIN_SECRET".to_string(), creds.admin_secret.clone()); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_WORKER_ACCOUNT") { values.insert("JAZZ_WORKER_ACCOUNT".to_string(), creds.app_id); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_WORKER_SECRET") { values.insert("JAZZ_WORKER_SECRET".to_string(), creds.backend_secret); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_SERVER_URL") { values.insert("JAZZ_SERVER_URL".to_string(), server.to_string()); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_SYNC_SERVER") { values.insert("JAZZ_SYNC_SERVER".to_string(), server.to_string()); } println!("✓ Jazz2 env-store credentials created"); } } if needs_auth_account { if prompt_confirm("Generate new Jazz2 auth app credentials now? (y/N): ")? { println!("Creating Jazz2 auth app credentials..."); let name = cf_cfg .bootstrap_jazz_auth_name .as_deref() .unwrap_or(&default_auth_name); let server = cf_cfg .bootstrap_jazz_auth_peer .as_deref() .unwrap_or(default_server); let creds = create_jazz_app_credentials(name)?; if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_AUTH_APP_ID") { values.insert("JAZZ_AUTH_APP_ID".to_string(), creds.app_id.clone()); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_AUTH_BACKEND_SECRET") { values.insert( "JAZZ_AUTH_BACKEND_SECRET".to_string(), creds.backend_secret.clone(), ); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_AUTH_ADMIN_SECRET") { values.insert( "JAZZ_AUTH_ADMIN_SECRET".to_string(), creds.admin_secret.clone(), ); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_AUTH_WORKER_ACCOUNT_ID") { values.insert("JAZZ_AUTH_WORKER_ACCOUNT_ID".to_string(), creds.app_id); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_AUTH_WORKER_ACCOUNT_SECRET") { values.insert( "JAZZ_AUTH_WORKER_ACCOUNT_SECRET".to_string(), creds.backend_secret, ); } if cf_cfg .bootstrap_secrets .iter() .any(|k| k == "JAZZ_AUTH_SERVER_URL") { values.insert("JAZZ_AUTH_SERVER_URL".to_string(), server.to_string()); } println!("✓ Jazz2 auth credentials created"); } } } for key in &cf_cfg.bootstrap_secrets { if values.contains_key(key) { continue; } if key == "ENV_API_TOKEN" || key == "FLOW_ENV_TOKEN" { let value = prompt_secret(&format!("{} (leave empty to auto-generate): ", key))?; let value = match value { Some(value) => value, None => { if let Some(existing) = generated_env_token.clone() { existing } else { let token = generate_env_api_token(); generated_env_token = Some(token.clone()); token } } }; values.insert(key.clone(), value); continue; } let value = prompt_secret(&format!("{}: ", key))?; if let Some(value) = value { values.insert(key.clone(), value); } } values.retain(|_, value| !value.trim().is_empty()); if values.is_empty() { println!("No secrets provided; nothing to set."); return Ok(()); } println!("Setting Cloudflare secrets..."); deploy::set_cloudflare_secrets(project_root, Some(cfg), &values)?; println!("✓ Cloudflare secrets updated"); let mut auth = load_auth_config_raw()?; let bootstrap_token = values .get("ENV_API_TOKEN") .or_else(|| values.get("FLOW_ENV_TOKEN")) .cloned(); if let Some(token) = bootstrap_token { store_auth_token(&mut auth, token)?; let needs_default_api = auth .api_url .as_deref() .map(|url| url.contains("workers.dev")) .unwrap_or(true); if needs_default_api { auth.api_url = Some(DEFAULT_API_URL.to_string()); } save_auth_config(&auth)?; } let env_name = cf_cfg .environment .clone() .unwrap_or_else(|| "production".to_string()); let mut env_key_set: HashSet<String> = HashSet::new(); for key in cf_cfg.env_keys.iter().chain(cf_cfg.env_vars.iter()) { env_key_set.insert(key.clone()); } for (key, value) in &values { if env_key_set.contains(key) { if let Err(err) = set_project_env_var(key, value, &env_name, None) { eprintln!("⚠ Failed to store {} in env store: {}", key, err); } } } if generated_env_token.is_some() { if auth.token_source.as_deref() == Some("keychain") { println!("✓ Saved ENV_API_TOKEN to Keychain"); } else { println!( "✓ Saved ENV_API_TOKEN to {}", get_auth_config_path().display() ); } } Ok(()) } fn prompt_line(label: &str) -> Result<Option<String>> { print!("{}", label); io::stdout().flush()?; let mut value = String::new(); io::stdin().read_line(&mut value)?; let value = value.trim().to_string(); if value.is_empty() { Ok(None) } else { Ok(Some(value)) } } fn prompt_line_default(key: &str, default_value: Option<&str>) -> Result<Option<String>> { let label = if let Some(default_value) = default_value { format!("{} [{}]: ", key, default_value) } else { format!("{}: ", key) }; let value = prompt_line(&label)?; if value.is_none() { Ok(default_value.map(|value| value.to_string())) } else { Ok(value) } } fn prompt_value(key: &str, default_value: Option<&str>, secret: bool) -> Result<Option<String>> { if secret { return prompt_secret(&format!("{}: ", key)); } let default_value = default_value.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed) } }); let label = if let Some(default_value) = default_value { format!("{} [{}]: ", key, default_value) } else { format!("{}: ", key) }; let value = prompt_line(&label)?; if value.is_none() { Ok(default_value.map(|value| value.to_string())) } else { Ok(value) } } fn prompt_confirm(label: &str) -> Result<bool> { print!("{}", label); io::stdout().flush()?; if std::io::stdin().is_terminal() { if let Ok(()) = crossterm::terminal::enable_raw_mode() { let read = crossterm::event::read(); let _ = crossterm::terminal::disable_raw_mode(); if let Ok(crossterm::event::Event::Key(key)) = read { println!(); return Ok(matches!( key.code, crossterm::event::KeyCode::Char('y' | 'Y') )); } } } let value = prompt_line("")?; Ok(matches!( value .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str(), "y" | "yes" )) } fn generate_env_api_token() -> String { format!("cloud_{}", Uuid::new_v4().simple()) } fn prompt_secret(label: &str) -> Result<Option<String>> { let value = rpassword::prompt_password(label)?; let value = value.trim().to_string(); if value.is_empty() { Ok(None) } else { Ok(Some(value)) } } fn setup(env_file: Option<PathBuf>, environment: Option<String>) -> Result<()> { let cwd = std::env::current_dir()?; let flow_path = find_flow_toml(&cwd); let (project_root, flow_cfg) = if let Some(path) = flow_path { let cfg = config::load(&path)?; let root = path.parent().unwrap_or(&cwd).to_path_buf(); (root, Some(cfg)) } else { (cwd, None) }; let cf_cfg = flow_cfg.as_ref().and_then(|cfg| cfg.cloudflare.as_ref()); let default_env = environment .clone() .or_else(|| cf_cfg.and_then(|cfg| cfg.environment.clone())); if env_file.is_none() { if let Some(cfg) = cf_cfg { if is_cloud_source(cfg.env_source.as_deref()) { let env = default_env.unwrap_or_else(|| "production".to_string()); return guide(&env); } } } let defaults = EnvSetupDefaults { env_file, environment: default_env, }; let Some(result) = run_env_setup(&project_root, defaults)? else { return Ok(()); }; if !result.apply { println!("Env setup canceled."); return Ok(()); } let Some(env_file) = result.env_file else { println!("No env file selected; nothing to push."); return Ok(()); }; let content = fs::read_to_string(&env_file) .with_context(|| format!("failed to read {}", env_file.display()))?; let vars = parse_env_file(&content); if vars.is_empty() { println!("No env vars found in {}", env_file.display()); return Ok(()); } if result.selected_keys.is_empty() { println!("No keys selected; nothing to push."); return Ok(()); } let mut selected = HashMap::new(); for key in result.selected_keys { if let Some(value) = vars.get(&key) { selected.insert(key, value.clone()); } } if selected.is_empty() { println!("No matching keys found in {}", env_file.display()); return Ok(()); } push_vars(&result.environment, selected) } fn show_keys() -> Result<()> { let cwd = std::env::current_dir()?; let flow_path = find_flow_toml(&cwd) .ok_or_else(|| anyhow::anyhow!("flow.toml not found. Run `f init` first."))?; let cfg = config::load(&flow_path)?; let label = resolve_env_target() .map(|target| env_target_label(&target)) .unwrap_or_else(|_| { cfg.project_name .clone() .unwrap_or_else(|| "unknown".to_string()) }); if let Some(cf_cfg) = cfg.cloudflare.as_ref() { println!("Env keys for {}", label); println!("─────────────────────────────"); if let Some(source) = cf_cfg.env_source.as_deref() { println!("Source: {}", source); } if let Some(environment) = cf_cfg.environment.as_deref() { println!("Environment: {}", environment); } if let Some(apply) = cf_cfg.env_apply.as_deref() { println!("Apply: {}", apply); } println!(); let mut secrets = cf_cfg.env_keys.clone(); secrets.sort(); let mut vars = cf_cfg.env_vars.clone(); vars.sort(); if secrets.is_empty() && vars.is_empty() { println!("No env keys configured."); return Ok(()); } if !secrets.is_empty() { println!("Secrets:"); for key in &secrets { if cf_cfg.env_defaults.contains_key(key) { println!(" {} (default set)", key); } else { println!(" {}", key); } } println!(); } if !vars.is_empty() { println!("Vars:"); for key in &vars { let default_value = cf_cfg .env_defaults .get(key) .map(|value| format_default_hint(value)); if let Some(default_value) = default_value { println!(" {} = {}", key, default_value); } else { println!(" {}", key); } } println!(); } let mut extra_defaults: Vec<_> = cf_cfg .env_defaults .keys() .filter(|key| !secrets.contains(*key) && !vars.contains(*key)) .cloned() .collect(); extra_defaults.sort(); if !extra_defaults.is_empty() { println!("Defaults (not in env_keys/env_vars):"); for key in extra_defaults { if let Some(value) = cf_cfg.env_defaults.get(&key) { println!(" {} = {}", key, format_default_hint(value)); } } } return Ok(()); } if let Some(storage) = cfg.storage.as_ref() { println!("Storage env keys for {}", label); println!("─────────────────────────────"); println!("Provider: {}", storage.provider); println!(); if storage.envs.is_empty() { println!("No storage envs configured."); return Ok(()); } for env_cfg in &storage.envs { println!("{}", env_cfg.name); if let Some(description) = env_cfg.description.as_deref() { println!(" {}", description); } if env_cfg.variables.is_empty() { println!(" (no variables)"); } else { for variable in &env_cfg.variables { match variable.default.as_deref() { Some(default) if !default.is_empty() => { println!(" {} = {}", variable.key, format_default_hint(default)); } Some(_) => println!(" {} (default: empty)", variable.key), None => println!(" {}", variable.key), } } } println!(); } return Ok(()); } anyhow::bail!("No [cloudflare] or [storage] env keys configured in flow.toml"); } /// List env vars for this project. fn list(environment: &str) -> Result<()> { if local_env_enabled() { let target = resolve_env_target()?; let label = env_target_label(&target); let vars = read_local_env_vars(&target, environment)?; println!("Space: {}", label); println!("Environment: {}", environment); println!("Backend: local"); println!("─────────────────────────────"); if vars.is_empty() { println!("No env vars set."); return Ok(()); } let mut keys: Vec<_> = vars.keys().collect(); keys.sort(); for key in keys { let value = &vars[key]; let masked = if value.len() > 8 { format!("{}...", &value[..4]) } else { "****".to_string() }; println!(" {} = {}", key, masked); } println!(); println!("{} env var(s)", vars.len()); return Ok(()); } let target = resolve_env_target()?; let label = env_target_label(&target); let auth = load_auth_config()?; let token = auth .token .as_ref() .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `f env login` first."))?; require_env_read_unlock()?; let api_url = get_api_url(&auth); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let (vars, descriptions, backend_label) = match &target { EnvTarget::Personal { space } => { let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; url.query_pairs_mut() .append_pair("environment", environment); if let Some(space) = space { url.query_pairs_mut().append_pair("space", space); } let resp = client .get(url) .header("Authorization", format!("Bearer {}", token)) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if resp.status() == 404 { bail!("Personal env vars not found."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let data: PersonalEnvResponse = resp.json().context("failed to parse response")?; (data.env, None, "cloud") } EnvTarget::Project { name } => { let entries = fetch_project_cloud_env_entries(name, environment, &[], &api_url, token, &client)?; (entries.vars, Some(entries.descriptions), "cloud (sealed)") } }; println!("Space: {}", label); println!("Environment: {}", environment); println!("Backend: {}", backend_label); println!("─────────────────────────────"); if vars.is_empty() { println!("No env vars set."); return Ok(()); } let mut keys: Vec<_> = vars.keys().collect(); keys.sort(); for key in keys { let value = &vars[key]; // Mask the value (show first 4 chars if long enough) let masked = if value.len() > 8 { format!("{}...", &value[..4]) } else { "****".to_string() }; // Show description if available if let Some(desc) = descriptions.as_ref().and_then(|map| map.get(key)) { println!(" {} = {} # {}", key, masked, desc); } else { println!(" {} = {}", key, masked); } } println!(); println!("{} env var(s)", vars.len()); Ok(()) } /// Set a personal (global) env var. pub(crate) fn set_personal_env_var(key: &str, value: &str) -> Result<()> { if key.is_empty() { bail!("Key cannot be empty"); } let target = resolve_personal_target()?; let environment = "production"; if local_env_enabled() { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set personal env var locally: {} (stored at {})", key, path.display() ); return Ok(()); } let auth = load_auth_config()?; let token = match auth.token.as_ref() { Some(token) => token, None => { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set personal env var locally: {} (stored at {})", key, path.display() ); return Ok(()); } }; let api_url = get_api_url(&auth); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; if let EnvTarget::Personal { ref space } = target { if let Some(space) = space.as_ref() { url.query_pairs_mut().append_pair("space", space); } } let mut vars = HashMap::new(); vars.insert(key.to_string(), value.to_string()); let body = serde_json::json!({ "vars": vars, }); let resp = client .post(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { if std::io::stdin().is_terminal() && prompt_confirm("Cloud auth failed. Store locally instead? (y/N): ")? { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set personal env var locally: {} (stored at {})", key, path.display() ); return Ok(()); } bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); let err = anyhow::anyhow!("API error {}: {}", status, body); if is_local_fallback_error(&err) && std::io::stdin().is_terminal() && prompt_confirm("Cloud unavailable. Store locally instead? (y/N): ")? { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set personal env var locally: {} (stored at {})", key, path.display() ); return Ok(()); } return Err(err); } println!("✓ Set personal env var: {}", key); Ok(()) } pub fn set_project_env_var( key: &str, value: &str, environment: &str, description: Option<&str>, ) -> Result<()> { set_project_env_var_internal(key, value, environment, description) } fn set_project_env_var_internal( key: &str, value: &str, environment: &str, description: Option<&str>, ) -> Result<()> { if key.is_empty() { bail!("Key cannot be empty"); } let target = resolve_env_target()?; if local_env_enabled() { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set env var locally: {} ({} stored at {})", key, environment, path.display() ); return Ok(()); } let auth = load_auth_config()?; let token = match auth.token.as_ref() { Some(token) => token, None => { if std::io::stdin().is_terminal() && prompt_confirm("Not logged in to cloud. Store locally instead? (y/N): ")? { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set env var locally: {} ({} stored at {})", key, environment, path.display() ); return Ok(()); } bail!("Not logged in. Run `f env login` first."); } }; let api_url = get_api_url(&auth); let resolved_value = value.to_string(); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let mut vars = HashMap::new(); vars.insert(key.to_string(), resolved_value.clone()); let mut descriptions = HashMap::new(); if let Some(desc) = description { descriptions.insert(key.to_string(), desc.to_string()); } let mirrored_plaintext = match &target { EnvTarget::Personal { space } => { let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; if let Some(space) = space { url.query_pairs_mut().append_pair("space", space); } let body = serde_json::json!({ "vars": &vars, "environment": environment, }); let resp = client .post(url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); let err = anyhow::anyhow!("API error {}: {}", status, body); if is_local_fallback_error(&err) && std::io::stdin().is_terminal() && prompt_confirm("Cloud unavailable. Store locally instead? (y/N): ")? { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set env var locally: {} ({} stored at {})", key, environment, path.display() ); return Ok(()); } return Err(err); } false } EnvTarget::Project { name } => match write_project_cloud_env_entries( name, environment, &vars, &descriptions, &api_url, token, &client, ) { Ok(mirror) => mirror, Err(err) => { if is_local_fallback_error(&err) && std::io::stdin().is_terminal() && prompt_confirm("Cloud unavailable. Store locally instead? (y/N): ")? { let path = set_local_env_var(&target, environment, key, value)?; println!( "✓ Set env var locally: {} ({} stored at {})", key, environment, path.display() ); return Ok(()); } return Err(err); } }, }; let masked = if resolved_value.len() > 8 { format!("{}...", &resolved_value[..4]) } else { "****".to_string() }; if let Some(desc) = description { if mirrored_plaintext && matches!(target, EnvTarget::Project { .. }) { println!( "✓ Set {}={} ({}) - {} [sealed + plaintext host mirror]", key, masked, environment, desc ); } else if matches!(target, EnvTarget::Project { .. }) { println!( "✓ Set {}={} ({}) - {} [sealed]", key, masked, environment, desc ); } else { println!("✓ Set {}={} ({}) - {}", key, masked, environment, desc); } } else { if mirrored_plaintext && matches!(target, EnvTarget::Project { .. }) { println!( "✓ Set {}={} ({}) [sealed + plaintext host mirror]", key, masked, environment ); } else if matches!(target, EnvTarget::Project { .. }) { println!("✓ Set {}={} ({}) [sealed]", key, masked, environment); } else { println!("✓ Set {}={} ({})", key, masked, environment); } } Ok(()) } /// Show current auth status. fn status() -> Result<()> { if local_env_enabled() { println!("Local Environment Manager"); println!("─────────────────────────────"); if let Ok(root) = local_env_root() { println!("Root: {}", root.display()); } if let Ok(target) = resolve_env_target() { println!("Space: {}", env_target_label(&target)); } println!(); println!("Commands:"); println!(" f env list - List env vars"); println!(" f env set K=V - Set personal env var"); println!(" f env project set -e <env> K=V - Set project env var"); println!(" f env get ... - Read env vars"); println!(" f env run -- <cmd> - Run with env vars injected"); println!(" f env keys - Show configured env keys"); println!(" f env guide - Guided env setup from flow.toml"); return Ok(()); } let auth = load_auth_config_raw()?; println!("Cloud Environment Manager"); println!("─────────────────────────────"); let api_url = get_api_url(&auth); if let Some(ref token) = auth.token { let masked = format!("{}...", &token[..7.min(token.len())]); println!("Token: {}", masked); println!("API: {}", api_url); } else if auth.token_source.as_deref() == Some("keychain") { println!("Token: stored in Keychain"); println!("API: {}", api_url); } else { println!("Status: Not logged in"); println!(); println!("Run `f env login` to authenticate."); return Ok(()); } if let Ok(target) = resolve_env_target() { println!("Space: {}", env_target_label(&target)); } println!(); println!("Commands:"); println!(" f env sync - Sync project settings"); println!(" f env unlock - Unlock env reads (Touch ID on macOS)"); println!(" f env pull - Fetch env vars"); println!(" f env push - Push .env to cloud"); println!(" f env guide - Guided env setup from flow.toml"); println!(" f env apply - Apply cloud envs to Cloudflare"); println!(" f env bootstrap - Bootstrap Cloudflare secrets"); println!(" f env setup - Interactive env setup"); println!(" f env list - List env vars"); println!(" f env keys - Show configured env keys"); println!(" f env set K=V - Set personal env var"); println!(" f env project set -e <env> K=V - Set project env var"); Ok(()) } /// Parse a .env file into key-value pairs. pub(crate) fn parse_env_file(content: &str) -> HashMap<String, String> { let mut vars = HashMap::new(); for line in content.lines() { let line = line.trim(); // Skip empty lines and comments if line.is_empty() || line.starts_with('#') { continue; } // Parse KEY=VALUE if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim(); // Remove surrounding quotes let value = value .strip_prefix('"') .and_then(|s| s.strip_suffix('"')) .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) .unwrap_or(value); if !key.is_empty() { vars.insert(key.to_string(), value.to_string()); } } } vars } /// Fetch env vars from cloud (personal or project). fn fetch_env_vars( target: &EnvTarget, environment: &str, keys: &[String], include_environment: bool, ) -> Result<HashMap<String, String>> { if local_env_enabled() { if matches!(target, EnvTarget::Personal { .. }) && !keys.is_empty() { return fetch_local_personal_env_vars(keys); } let vars = read_local_env_vars(target, environment)?; return Ok(select_requested_env_keys(vars, keys)); } if matches!(target, EnvTarget::Personal { .. }) { match fetch_local_personal_env_vars(keys) { Ok(vars) if !vars.is_empty() => return Ok(vars), Ok(_) => {} Err(err) => { if !has_cloud_auth_token() { return Err(err); } } } } let auth = load_auth_config()?; let token = match auth.token.as_ref() { Some(token) => token, None => { if std::io::stdin().is_terminal() && prompt_confirm("Not logged in to cloud. Read local envs instead? (y/N): ")? { let vars = read_local_env_vars(target, environment)?; return Ok(select_requested_env_keys(vars, keys)); } bail!("Not logged in. Run `f env login` first."); } }; require_env_read_unlock()?; let api_url = get_api_url(&auth); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; if let EnvTarget::Project { name } = target { let entries = match fetch_project_cloud_env_entries( name, environment, keys, &api_url, token, &client, ) { Ok(entries) => entries, Err(err) => { if is_local_fallback_error(&err) && std::io::stdin().is_terminal() && prompt_confirm("Cloud unavailable. Read local envs instead? (y/N): ")? { let vars = read_local_env_vars(target, environment)?; return Ok(select_requested_env_keys(vars, keys)); } return Err(err); } }; return Ok(entries.vars); } let mut url = match target { EnvTarget::Personal { space } => { let mut url = Url::parse(&format!("{}/api/env/personal", api_url))?; if include_environment { url.query_pairs_mut() .append_pair("environment", environment); } if let Some(space) = space { url.query_pairs_mut().append_pair("space", space); } url } EnvTarget::Project { name } => { let mut url = Url::parse(&format!("{}/api/env/{}", api_url, name))?; url.query_pairs_mut() .append_pair("environment", environment); url } }; if !keys.is_empty() { url.query_pairs_mut().append_pair("keys", &keys.join(",")); } let resp = client .get(url) .header("Authorization", format!("Bearer {}", token)) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { if std::io::stdin().is_terminal() && prompt_confirm("Cloud auth failed. Read local envs instead? (y/N): ")? { let vars = read_local_env_vars(target, environment)?; return Ok(select_requested_env_keys(vars, keys)); } bail!("Unauthorized. Check your token with `f env login`."); } if resp.status() == 404 { match target { EnvTarget::Personal { .. } => bail!("Personal env vars not found."), EnvTarget::Project { .. } => { bail!("Project not found. Create it with `f env push` first.") } } } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); let err = anyhow::anyhow!("API error {}: {}", status, body); if is_local_fallback_error(&err) && std::io::stdin().is_terminal() && prompt_confirm("Cloud unavailable. Read local envs instead? (y/N): ")? { let vars = read_local_env_vars(target, environment)?; return Ok(select_requested_env_keys(vars, keys)); } return Err(err); } match target { EnvTarget::Personal { .. } => { let data: PersonalEnvResponse = resp.json().context("failed to parse response")?; Ok(data.env) } EnvTarget::Project { .. } => { let data: EnvResponse = resp.json().context("failed to parse response")?; Ok(data.env) } } } pub fn fetch_project_env_vars( environment: &str, keys: &[String], ) -> Result<HashMap<String, String>> { let target = resolve_env_target()?; fetch_env_vars(&target, environment, keys, true) } pub fn fetch_personal_env_vars(keys: &[String]) -> Result<HashMap<String, String>> { let target = resolve_personal_target()?; fetch_env_vars(&target, "production", keys, false) } /// Get specific env vars and print to stdout. fn get_vars(keys: &[String], personal: bool, environment: &str, format: &str) -> Result<()> { let target = if personal { resolve_personal_target()? } else { resolve_env_target()? }; let vars = fetch_env_vars(&target, environment, keys, !personal)?; if vars.is_empty() { bail!("No env vars found"); } match format { "json" => { let json = serde_json::to_string_pretty(&vars)?; println!("{}", json); } "value" => { if keys.len() != 1 { bail!("'value' format requires exactly one key"); } let key = &keys[0]; if let Some(value) = vars.get(key) { print!("{}", value); // No newline for piping } else { bail!("Key '{}' not found", key); } } "env" | _ => { // Default: KEY=VALUE format let mut sorted_keys: Vec<_> = vars.keys().collect(); sorted_keys.sort(); for key in sorted_keys { let value = &vars[key]; // Escape for shell let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); println!("{}=\"{}\"", key, escaped); } } } Ok(()) } /// Run a command with env vars injected from cloud. fn run_with_env( personal: bool, environment: &str, keys: &[String], command: &[String], ) -> Result<()> { if command.is_empty() { bail!("No command specified"); } let target = if personal { resolve_personal_target()? } else { resolve_env_target()? }; let vars = fetch_env_vars(&target, environment, keys, !personal)?; let (cmd, args) = command.split_first().unwrap(); let mut child = Command::new(cmd); child.args(args); // Inject env vars for (key, value) in &vars { child.env(key, value); } let status = child .status() .with_context(|| format!("failed to run '{}'", cmd))?; std::process::exit(status.code().unwrap_or(1)); } // ============================================================================= // Service Token Management // ============================================================================= #[derive(Debug, Deserialize)] struct CreateTokenResponse { #[allow(dead_code)] success: bool, token: String, #[serde(rename = "projectName")] project_name: String, name: String, permissions: String, } #[derive(Debug, Deserialize)] struct TokenEntry { name: String, #[serde(rename = "projectName")] project_name: String, permissions: String, #[serde(rename = "createdAt")] #[allow(dead_code)] created_at: Option<String>, #[serde(rename = "lastUsedAt")] last_used_at: Option<String>, revoked: bool, } #[derive(Debug, Deserialize)] struct ListTokensResponse { tokens: Vec<TokenEntry>, } /// Create a new service token for the current project. fn token_create(name: Option<&str>, permissions: &str) -> Result<()> { let auth = load_auth_config()?; let token = auth .token .as_ref() .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `f env login` first."))?; let target = resolve_env_target()?; let project = env_target_name_for_tokens(&target)?; let default_name = format!("{}-service", project); let token_name = name.unwrap_or(&default_name); let api_url = get_api_url(&auth); println!("Creating service token for '{}'...", project); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let url = format!("{}/api/env/tokens", api_url); let body = serde_json::json!({ "projectName": project, "name": token_name, "permissions": permissions, }); let resp = client .post(&url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if resp.status() == 403 { bail!("You don't own this project."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let data: CreateTokenResponse = resp.json().context("failed to parse response")?; println!(); println!("✓ Service token created!"); println!(); println!("Token: {}", data.token); println!("Project: {}", data.project_name); println!("Name: {}", data.name); println!("Permissions: {}", data.permissions); println!(); println!("IMPORTANT: Save this token now. It won't be shown again."); println!(); println!( "This token can ONLY access env vars for '{}'.", data.project_name ); println!("If the host is compromised, revoke it with:"); println!(" f env token revoke {}", data.name); Ok(()) } /// List service tokens for the current user. fn token_list() -> Result<()> { let auth = load_auth_config()?; let token = auth .token .as_ref() .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `f env login` first."))?; let api_url = get_api_url(&auth); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let url = format!("{}/api/env/tokens", api_url); let resp = client .get(&url) .header("Authorization", format!("Bearer {}", token)) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } let data: ListTokensResponse = resp.json().context("failed to parse response")?; if data.tokens.is_empty() { println!("No service tokens found."); println!(); println!("Create one with: f env token create"); return Ok(()); } println!("Service Tokens"); println!("─────────────────────────────"); for entry in &data.tokens { let status = if entry.revoked { " (revoked)" } else { "" }; println!( " {} → {} [{}]{}", entry.name, entry.project_name, entry.permissions, status ); if let Some(last_used) = &entry.last_used_at { println!(" Last used: {}", last_used); } } println!(); println!("{} token(s)", data.tokens.len()); Ok(()) } /// Revoke a service token. fn token_revoke(name: &str) -> Result<()> { let auth = load_auth_config()?; let token = auth .token .as_ref() .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `f env login` first."))?; let target = resolve_env_target()?; let project = env_target_name_for_tokens(&target)?; let api_url = get_api_url(&auth); println!("Revoking token '{}' for project '{}'...", name, project); let client = crate::http_client::blocking_with_timeout(std::time::Duration::from_secs(30))?; let url = format!("{}/api/env/tokens", api_url); let body = serde_json::json!({ "name": name, "projectName": project, }); let resp = client .delete(&url) .header("Authorization", format!("Bearer {}", token)) .json(&body) .send() .context("failed to connect to cloud")?; if resp.status() == 401 { bail!("Unauthorized. Check your token with `f env login`."); } if resp.status() == 404 { bail!("Token '{}' not found for project '{}'.", name, project); } if !resp.status().is_success() { let status = resp.status(); let body = resp.text().unwrap_or_default(); bail!("API error {}: {}", status, body); } println!("✓ Token '{}' revoked.", name); println!(); println!("Any host using this token will no longer be able to fetch env vars."); Ok(()) } #[cfg(test)] mod tests { use super::{ SealedEnvContent, SealedEnvItem, SealedEnvRecipientGrant, create_env_sealer_identity, decrypt_project_env_value, ensure_private_dir, is_local_keychain_ref, local_keychain_ref, project_env_backend_from_config, project_plaintext_cloud_mirror_required_for_config, seal_project_env_value, write_private_file, }; use crate::config::Config; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; #[test] fn project_env_backend_detects_local_host_source() { let cfg: Config = toml::from_str( r#" [host] env_source = "local" "#, ) .expect("host env_source should parse"); assert_eq!(project_env_backend_from_config(&cfg), Some("local")); } #[test] fn project_env_backend_detects_cloud_source() { let cfg: Config = toml::from_str( r#" [cloudflare] env_source = "cloud" "#, ) .expect("cloudflare env_source should parse"); assert_eq!(project_env_backend_from_config(&cfg), Some("cloud")); } #[test] fn project_env_backend_requires_unambiguous_sources() { let cfg: Config = toml::from_str( r#" [host] env_source = "local" [cloudflare] env_source = "cloud" "#, ) .expect("mixed env_source config should parse"); assert_eq!(project_env_backend_from_config(&cfg), None); } #[test] fn plaintext_cloud_mirror_requires_cloud_host_with_service_token() { let cfg: Config = toml::from_str( r#" [host] env_source = "cloud" service_token = "cloud_test_123" "#, ) .expect("host cloud config should parse"); assert!(project_plaintext_cloud_mirror_required_for_config(&cfg)); } #[test] fn plaintext_cloud_mirror_ignores_non_cloud_or_missing_token() { let no_token: Config = toml::from_str( r#" [host] env_source = "cloud" "#, ) .expect("host cloud config without token should parse"); let local: Config = toml::from_str( r#" [host] env_source = "local" service_token = "cloud_test_123" "#, ) .expect("host local config should parse"); assert!(!project_plaintext_cloud_mirror_required_for_config( &no_token )); assert!(!project_plaintext_cloud_mirror_required_for_config(&local)); } #[test] fn local_keychain_refs_use_reserved_prefix() { let reference = local_keychain_ref("DESIGNER_LINEAR_API_KEY"); assert!(is_local_keychain_ref(&reference)); assert!(!is_local_keychain_ref("example_secret_value")); } #[test] fn sealed_project_env_roundtrip_decrypts_for_registered_recipient() { let identity = create_env_sealer_identity().expect("create identity"); let write_item = seal_project_env_value( "example_secret_value", &identity, std::slice::from_ref(&identity.sealer_id), ) .expect("seal env value"); let read_item = SealedEnvItem { description: Some("Linear API key".to_string()), available_recipient_count: write_item.recipients.len(), content: Some(SealedEnvContent { algorithm: write_item.content.algorithm, ciphertext_b64: write_item.content.ciphertext_b64, nonce_b64: write_item.content.nonce_b64, }), recipients: write_item .recipients .into_iter() .map(|recipient| SealedEnvRecipientGrant { recipient_id: recipient.recipient_id, sender_id: Some(recipient.sender_id), wrapped_key_b64: recipient.wrapped_key_b64, nonce_material_b64: recipient.nonce_material_b64, }) .collect(), }; let value = decrypt_project_env_value(&read_item, &identity).expect("decrypt env value"); assert_eq!(value.as_deref(), Some("example_secret_value")); } #[cfg(unix)] #[test] fn ensure_private_dir_enforces_owner_only_permissions() { let dir = tempdir().expect("tempdir"); let nested = dir.path().join("a").join("b"); ensure_private_dir(&nested).expect("create private dir"); let mode = nested.metadata().expect("metadata").permissions().mode() & 0o777; assert_eq!(mode, 0o700); } #[cfg(unix)] #[test] fn write_private_file_enforces_owner_only_permissions() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("secrets").join("production.env"); write_private_file(&path, "API_KEY=\"secret\"\n").expect("write private file"); let file_mode = path.metadata().expect("metadata").permissions().mode() & 0o777; let dir_mode = path .parent() .expect("parent") .metadata() .expect("dir metadata") .permissions() .mode() & 0o777; assert_eq!(file_mode, 0o600); assert_eq!(dir_mode, 0o700); } } ================================================ FILE: src/env_setup.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crossterm::{ event::{self, Event as CEvent, KeyCode, KeyEvent}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ignore::WalkBuilder; use ratatui::{ Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, }; use crate::env::parse_env_file; #[derive(Debug, Clone, Default)] pub struct EnvSetupDefaults { pub env_file: Option<PathBuf>, pub environment: Option<String>, } #[derive(Debug, Clone)] pub struct EnvSetupResult { pub env_file: Option<PathBuf>, pub environment: String, pub selected_keys: Vec<String>, pub apply: bool, } pub fn run_env_setup( project_root: &Path, defaults: EnvSetupDefaults, ) -> Result<Option<EnvSetupResult>> { let env_files = discover_env_files(project_root)?; if env_files.is_empty() { println!("No .env files found."); println!("Create one (for example .env) and try: f env setup"); return Ok(None); } let mut app = EnvSetupApp::new(project_root, env_files, defaults); enable_raw_mode().context("failed to enable raw mode")?; let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).context("failed to create terminal backend")?; let app_result = run_app(&mut terminal, &mut app); disable_raw_mode().ok(); let _ = terminal.show_cursor(); drop(terminal); let mut stdout = std::io::stdout(); execute!(stdout, LeaveAlternateScreen).ok(); app_result } #[derive(Debug, Clone, Copy)] enum SetupStep { EnvFile, EnvTarget, CustomEnv, Keys, Confirm, } struct EnvFileChoice { label: String, path: Option<PathBuf>, } struct EnvTargetChoice { label: String, value: Option<String>, is_custom: bool, } struct EnvKeyItem { key: String, selected: bool, suspect: bool, suspect_reason: Option<String>, value_len: usize, } struct EnvSetupApp { project_root: PathBuf, step: SetupStep, env_files: Vec<EnvFileChoice>, selected_env_file: usize, env_targets: Vec<EnvTargetChoice>, selected_env_target: usize, custom_env: String, key_items: Vec<EnvKeyItem>, selected_key: usize, apply: bool, result: Option<EnvSetupResult>, } impl EnvSetupApp { fn new(project_root: &Path, env_files: Vec<PathBuf>, defaults: EnvSetupDefaults) -> Self { let env_file_choices = build_env_file_choices(project_root, &env_files); let selected_env_file = pick_default_env_file(project_root, &env_file_choices, defaults.env_file.as_ref()); let mut app = Self { project_root: project_root.to_path_buf(), step: SetupStep::EnvFile, env_files: env_file_choices, selected_env_file, env_targets: Vec::new(), selected_env_target: 0, custom_env: String::new(), key_items: Vec::new(), selected_key: 0, apply: true, result: None, }; let preferred = defaults .environment .as_deref() .map(|s| s.to_string()) .or_else(|| app.infer_env_target()); app.refresh_env_targets(preferred.as_deref()); app } fn infer_env_target(&self) -> Option<String> { let path = self.env_file_path()?; infer_env_target_from_file(&path) } fn refresh_env_targets(&mut self, preferred: Option<&str>) { let mut targets = vec![ EnvTargetChoice { label: "production (default)".to_string(), value: Some("production".to_string()), is_custom: false, }, EnvTargetChoice { label: "staging".to_string(), value: Some("staging".to_string()), is_custom: false, }, EnvTargetChoice { label: "dev".to_string(), value: Some("dev".to_string()), is_custom: false, }, ]; if let Some(env) = preferred { if !targets .iter() .any(|choice| choice.value.as_deref() == Some(env)) { targets.push(EnvTargetChoice { label: env.to_string(), value: Some(env.to_string()), is_custom: false, }); } } targets.push(EnvTargetChoice { label: "custom...".to_string(), value: None, is_custom: true, }); self.env_targets = targets; self.selected_env_target = pick_default_env_target(&self.env_targets, preferred); } fn refresh_keys(&mut self) { self.key_items.clear(); self.selected_key = 0; if let Some(path) = self.env_file_path() { if let Ok(items) = build_key_items(&path) { self.key_items = items; } } } fn env_file_path(&self) -> Option<PathBuf> { self.env_files .get(self.selected_env_file) .and_then(|choice| choice.path.clone()) } fn env_file_path_ref(&self) -> Option<&Path> { self.env_files .get(self.selected_env_file) .and_then(|choice| choice.path.as_deref()) } fn selected_env_target(&self) -> Option<String> { self.env_targets .get(self.selected_env_target) .and_then(|choice| choice.value.clone()) } fn finalize(&mut self) { let selected_keys = self .key_items .iter() .filter(|item| item.selected) .map(|item| item.key.clone()) .collect(); let environment = self .selected_env_target() .unwrap_or_else(|| "production".to_string()); self.result = Some(EnvSetupResult { env_file: self.env_file_path(), environment, selected_keys, apply: self.apply, }); } } fn run_app<B: ratatui::backend::Backend>( terminal: &mut Terminal<B>, app: &mut EnvSetupApp, ) -> Result<Option<EnvSetupResult>> { loop { terminal .draw(|f| draw_ui(f, app)) .map_err(|err| anyhow::anyhow!("failed to draw env setup UI: {err}"))?; if event::poll(std::time::Duration::from_millis(200))? { if let CEvent::Key(key) = event::read()? { if handle_key(app, key)? { return Ok(app.result.take()); } } } } } fn handle_key(app: &mut EnvSetupApp, key: KeyEvent) -> Result<bool> { match key.code { KeyCode::Char('q') => return Ok(true), KeyCode::Esc => return Ok(step_back(app)), _ => {} } match app.step { SetupStep::EnvFile => match key.code { KeyCode::Up => { select_prev(&mut app.selected_env_file, app.env_files.len()); let preferred = app.infer_env_target(); app.refresh_env_targets(preferred.as_deref()); } KeyCode::Down => { select_next(&mut app.selected_env_file, app.env_files.len()); let preferred = app.infer_env_target(); app.refresh_env_targets(preferred.as_deref()); } KeyCode::Enter => { let preferred = app.infer_env_target(); app.refresh_env_targets(preferred.as_deref()); app.step = SetupStep::EnvTarget; } _ => {} }, SetupStep::EnvTarget => match key.code { KeyCode::Up => select_prev(&mut app.selected_env_target, app.env_targets.len()), KeyCode::Down => select_next(&mut app.selected_env_target, app.env_targets.len()), KeyCode::Enter => { if app .env_targets .get(app.selected_env_target) .is_some_and(|choice| choice.is_custom) { app.custom_env.clear(); app.step = SetupStep::CustomEnv; } else if app.env_file_path().is_some() { app.refresh_keys(); if app.key_items.is_empty() { app.step = SetupStep::Confirm; } else { app.step = SetupStep::Keys; } } else { app.step = SetupStep::Confirm; } } _ => {} }, SetupStep::CustomEnv => match key.code { KeyCode::Enter => { if !app.custom_env.trim().is_empty() { app.env_targets.push(EnvTargetChoice { label: app.custom_env.trim().to_string(), value: Some(app.custom_env.trim().to_string()), is_custom: false, }); app.selected_env_target = app.env_targets.len().saturating_sub(2); if app.env_file_path().is_some() { app.refresh_keys(); app.step = if app.key_items.is_empty() { SetupStep::Confirm } else { SetupStep::Keys }; } else { app.step = SetupStep::Confirm; } } } KeyCode::Backspace => { app.custom_env.pop(); } KeyCode::Char(ch) => { if !ch.is_control() { app.custom_env.push(ch); } } _ => {} }, SetupStep::Keys => match key.code { KeyCode::Up => select_prev(&mut app.selected_key, app.key_items.len()), KeyCode::Down => select_next(&mut app.selected_key, app.key_items.len()), KeyCode::Char(' ') => { if let Some(item) = app.key_items.get_mut(app.selected_key) { item.selected = !item.selected; } } KeyCode::Enter => app.step = SetupStep::Confirm, _ => {} }, SetupStep::Confirm => match key.code { KeyCode::Char(' ') => app.apply = !app.apply, KeyCode::Enter => { app.finalize(); return Ok(true); } _ => {} }, } Ok(false) } fn draw_ui(f: &mut ratatui::Frame<'_>, app: &EnvSetupApp) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ] .as_ref(), ) .split(f.area()); let title = match app.step { SetupStep::EnvFile => "Env Setup: Select .env file", SetupStep::EnvTarget => "Select cloud environment", SetupStep::CustomEnv => "Enter custom environment", SetupStep::Keys => "Select keys to push", SetupStep::Confirm => "Confirm env setup", }; let header = Paragraph::new(Line::from(title)) .block(Block::default().borders(Borders::ALL).title("flow")) .alignment(ratatui::layout::Alignment::Center); f.render_widget(header, chunks[0]); match app.step { SetupStep::EnvFile => { let body = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)].as_ref()) .split(chunks[1]); let items = app .env_files .iter() .map(|choice| ListItem::new(Line::from(choice.label.clone()))) .collect::<Vec<_>>(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("Secrets source"), ) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.selected_env_file)); f.render_stateful_widget(list, body[0], &mut state); let preview_lines = build_env_preview_lines(&app.project_root, app.env_file_path_ref()); let preview = Paragraph::new(preview_lines) .block(Block::default().borders(Borders::ALL).title("Preview")) .wrap(Wrap { trim: true }); f.render_widget(preview, body[1]); } SetupStep::EnvTarget => { let items = app .env_targets .iter() .map(|choice| ListItem::new(Line::from(choice.label.clone()))) .collect::<Vec<_>>(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title("Environment")) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.selected_env_target)); f.render_stateful_widget(list, chunks[1], &mut state); } SetupStep::CustomEnv => { let prompt = format!("> {}", app.custom_env); let input = Paragraph::new(prompt) .block( Block::default() .borders(Borders::ALL) .title("Environment name"), ) .wrap(Wrap { trim: true }); f.render_widget(input, chunks[1]); } SetupStep::Keys => { let body = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(60), Constraint::Percentage(40)].as_ref()) .split(chunks[1]); let selected_count = app.key_items.iter().filter(|item| item.selected).count(); let items = app .key_items .iter() .map(|item| { let indicator = if item.selected { "[x]" } else { "[ ]" }; let flag = if item.suspect { " suspect" } else { "" }; let label = format!("{indicator} {}{flag}", item.key); ListItem::new(Line::from(label)) }) .collect::<Vec<_>>(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(format!( "Keys ({}/{})", selected_count, app.key_items.len() ))) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ); let mut state = ratatui::widgets::ListState::default(); state.select(Some(app.selected_key)); f.render_stateful_widget(list, body[0], &mut state); let detail_lines = build_key_detail_lines( &app.project_root, app.env_file_path_ref(), app.key_items.get(app.selected_key), ); let details = Paragraph::new(detail_lines) .block(Block::default().borders(Borders::ALL).title("Details")) .wrap(Wrap { trim: true }); f.render_widget(details, body[1]); } SetupStep::Confirm => { let env_file = app .env_file_path() .map(|p| relative_display(&app.project_root, &p)) .unwrap_or_else(|| "none".to_string()); let env_target = app .selected_env_target() .unwrap_or_else(|| "production".to_string()); let selected_count = app.key_items.iter().filter(|item| item.selected).count(); let apply = if app.apply { "yes" } else { "no" }; let summary = vec![ Line::from(vec![ Span::styled("Env file: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(env_file), ]), Line::from(vec![ Span::styled( "Environment: ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(env_target), ]), Line::from(vec![ Span::styled( "Keys selected: ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(format!("{}", selected_count)), ]), Line::from(vec![ Span::styled("Apply now: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(apply), ]), ]; let paragraph = Paragraph::new(summary) .block(Block::default().borders(Borders::ALL).title("Review")) .wrap(Wrap { trim: true }); f.render_widget(paragraph, chunks[1]); } } let help = match app.step { SetupStep::EnvFile => "Up/Down to move, Enter to select, Esc to cancel, q to cancel", SetupStep::EnvTarget => "Up/Down to move, Enter to select, Esc to back, q to cancel", SetupStep::CustomEnv => "Type name, Enter to confirm, Esc to back, q to cancel", SetupStep::Keys => { "Up/Down to move, Space to toggle, Enter to continue, Esc to back, q to cancel" } SetupStep::Confirm => "Space to toggle apply, Enter to finish, Esc to back, q to cancel", }; let footer = Paragraph::new(help) .block(Block::default().borders(Borders::ALL)) .alignment(ratatui::layout::Alignment::Center); f.render_widget(footer, chunks[2]); } fn build_env_preview_lines(project_root: &Path, env_file: Option<&Path>) -> Vec<Line<'static>> { let mut lines = Vec::new(); let Some(path) = env_file else { lines.push(Line::from("No env file selected.")); lines.push(Line::from("Secrets will not be set.")); return lines; }; lines.push(Line::from(vec![ Span::styled("File: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(relative_display(project_root, path)), ])); lines.push(Line::from("Values are hidden.")); let content = match fs::read_to_string(path) { Ok(content) => content, Err(_) => { lines.push(Line::from("Unable to read file.")); return lines; } }; let vars = parse_env_file(&content); if vars.is_empty() { lines.push(Line::from("No env vars found.")); return lines; } let mut entries: Vec<_> = vars.into_iter().collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); let suspect_count = entries .iter() .filter(|(_, value)| suspect_reason(value).is_some()) .count(); let total = entries.len(); lines.push(Line::from(format!( "Keys: {} (suspect: {})", total, suspect_count ))); lines.push(Line::from("! = likely test/local value")); let max_keys = 12usize; for (key, value) in entries.iter().take(max_keys) { let flag = if suspect_reason(value).is_some() { " !" } else { "" }; lines.push(Line::from(format!(" - {}{}", key, flag))); } if total > max_keys { lines.push(Line::from(format!("... +{} more", total - max_keys))); } lines } fn build_key_detail_lines( project_root: &Path, env_file: Option<&Path>, item: Option<&EnvKeyItem>, ) -> Vec<Line<'static>> { let mut lines = Vec::new(); let env_label = env_file .map(|path| relative_display(project_root, path)) .unwrap_or_else(|| "none".to_string()); lines.push(Line::from(format!("Env file: {}", env_label))); let Some(item) = item else { lines.push(Line::from("No key selected.")); return lines; }; lines.push(Line::from(format!("Key: {}", item.key))); lines.push(Line::from(format!( "Selected: {}", if item.selected { "yes" } else { "no" } ))); lines.push(Line::from(format!( "Status: {}", if item.suspect { "suspect" } else { "ok" } ))); if let Some(reason) = &item.suspect_reason { lines.push(Line::from(format!("Reason: {}", reason))); } lines.push(Line::from(format!("Value length: {}", item.value_len))); lines.push(Line::from("Values are hidden.")); if item.suspect { lines.push(Line::from("Tip: suspect values default to unchecked.")); } lines } fn select_prev(selected: &mut usize, len: usize) { if len == 0 { return; } if *selected == 0 { *selected = len.saturating_sub(1); } else { *selected -= 1; } } fn select_next(selected: &mut usize, len: usize) { if len == 0 { return; } if *selected + 1 >= len { *selected = 0; } else { *selected += 1; } } fn step_back(app: &mut EnvSetupApp) -> bool { match app.step { SetupStep::EnvFile => true, SetupStep::EnvTarget => { app.step = SetupStep::EnvFile; false } SetupStep::CustomEnv => { app.step = SetupStep::EnvTarget; false } SetupStep::Keys => { app.step = SetupStep::EnvTarget; false } SetupStep::Confirm => { if app.env_file_path().is_some() && !app.key_items.is_empty() { app.step = SetupStep::Keys; } else { app.step = SetupStep::EnvTarget; } false } } } fn relative_display(root: &Path, path: &Path) -> String { if let Ok(rel) = path.strip_prefix(root) { let rel = rel.to_string_lossy().to_string(); if rel.is_empty() { ".".to_string() } else { rel } } else { path.to_string_lossy().to_string() } } fn build_env_file_choices(project_root: &Path, env_files: &[PathBuf]) -> Vec<EnvFileChoice> { let mut choices = Vec::new(); choices.push(EnvFileChoice { label: "Skip (do not set secrets)".to_string(), path: None, }); for path in env_files { choices.push(EnvFileChoice { label: relative_display(project_root, path), path: Some(path.clone()), }); } choices } fn pick_default_env_file( project_root: &Path, choices: &[EnvFileChoice], preferred: Option<&PathBuf>, ) -> usize { if let Some(path) = preferred { if let Some((idx, _)) = choices .iter() .enumerate() .find(|(_, c)| c.path.as_ref() == Some(path)) { return idx; } } let candidates = [ ".env", ".env.production", ".env.staging", ".env.dev", ".env.local", ]; for candidate in candidates { let candidate_path = project_root.join(candidate); if let Some((idx, _)) = choices .iter() .enumerate() .find(|(_, c)| c.path.as_ref() == Some(&candidate_path)) { return idx; } } if choices.len() > 1 { 1 } else { 0 } } fn pick_default_env_target(targets: &[EnvTargetChoice], preferred: Option<&str>) -> usize { if let Some(env) = preferred { if let Some((idx, _)) = targets .iter() .enumerate() .find(|(_, choice)| choice.value.as_deref() == Some(env)) { return idx; } } 0 } fn build_key_items(path: &Path) -> Result<Vec<EnvKeyItem>> { let content = fs::read_to_string(path) .with_context(|| format!("failed to read env file {}", path.display()))?; let env = parse_env_file(&content); let mut keys: Vec<_> = env.into_iter().collect(); keys.sort_by(|a, b| a.0.cmp(&b.0)); Ok(keys .into_iter() .map(|(key, value)| { let reason = suspect_reason(&value); let suspect = reason.is_some(); EnvKeyItem { key, selected: !suspect, suspect: suspect || value.trim().is_empty(), suspect_reason: reason.map(|reason| reason.to_string()), value_len: value.len(), } }) .collect()) } fn suspect_reason(value: &str) -> Option<&'static str> { let trimmed = value.trim(); if trimmed.is_empty() { return Some("empty"); } let lowered = trimmed.to_lowercase(); if lowered.contains("sk_test") || lowered.contains("pk_test") { return Some("stripe_test"); } if lowered.contains("localhost") || lowered.contains("127.0.0.1") { return Some("localhost"); } if lowered.contains("example.com") || lowered.contains("example") { return Some("example"); } if lowered.contains("dummy") { return Some("dummy"); } if lowered.contains("test") { return Some("test"); } None } fn infer_env_target_from_file(path: &Path) -> Option<String> { let name = path.file_name()?.to_string_lossy().to_lowercase(); if name.contains("staging") { return Some("staging".to_string()); } if name.contains("dev") || name.contains("development") { return Some("dev".to_string()); } if name.contains("prod") || name.contains("production") { return Some("production".to_string()); } None } fn discover_env_files(root: &Path) -> Result<Vec<PathBuf>> { let walker = WalkBuilder::new(root) .hidden(false) .git_ignore(false) .git_global(false) .git_exclude(false) .max_depth(Some(10)) .filter_entry(|entry| { if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { let name = entry.file_name().to_string_lossy(); !matches!( name.as_ref(), "node_modules" | "target" | "dist" | "build" | ".git" | ".hg" | ".svn" | "__pycache__" | ".pytest_cache" | ".mypy_cache" | "venv" | ".venv" | "vendor" | "Pods" | ".cargo" | ".rustup" ) } else { true } }) .build(); let mut env_files = Vec::new(); for entry in walker.flatten() { if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { if let Some(name) = entry.path().file_name().and_then(|s| s.to_str()) { if name.starts_with(".env") && name != ".envrc" { env_files.push(entry.path().to_path_buf()); } } } } env_files.sort(); env_files.dedup(); Ok(env_files) } ================================================ FILE: src/explain_commits.rs ================================================ //! Explain commits via AI — generate markdown summaries for git commits. //! //! Used by `f explain-commits N` and as a post-sync hook to auto-explain //! new commits in tracked repos. use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use chrono::Utc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::cli::ExplainCommitsCommand; use crate::config; use crate::projects; const DEFAULT_OUTPUT_DIR: &str = "docs/commits"; const DEFAULT_BATCH_SIZE: usize = 10; const MAX_DIFF_CHARS: usize = 8000; const AI_TASK_SCRIPT: &str = "~/code/org/gen/new/ai/scripts/ai-task.sh"; const DEFAULT_PROVIDER: &str = "nvidia"; const DEFAULT_MODEL: &str = "moonshotai/kimi-k2.5"; // -- Index tracking -- #[derive(Debug, Clone, Serialize, Deserialize)] struct CommitIndex { version: u32, commits: HashMap<String, CommitEntry>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CommitEntry { digest: String, file: String, at: String, #[serde(default)] sha: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExplainedCommit { pub sha: String, pub short_sha: String, pub subject: String, pub author: String, pub date: String, pub summary: String, pub changes: String, pub files: Vec<String>, pub markdown_file: String, pub generated_at: String, } impl Default for CommitIndex { fn default() -> Self { Self { version: 1, commits: HashMap::new(), } } } fn index_path(output_dir: &Path) -> PathBuf { output_dir.join(".index.json") } fn load_index(output_dir: &Path) -> CommitIndex { let path = index_path(output_dir); if path.exists() { fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { CommitIndex::default() } } fn save_index(output_dir: &Path, index: &CommitIndex) -> Result<()> { let path = index_path(output_dir); let json = serde_json::to_string_pretty(index)?; fs::write(&path, json).context("failed to write commit index")?; Ok(()) } fn compute_digest(sha: &str, message: &str, diff: &str) -> String { let mut hasher = Sha256::new(); hasher.update(sha.as_bytes()); hasher.update(b"\n"); hasher.update(message.as_bytes()); hasher.update(b"\n"); hasher.update(diff.as_bytes()); format!("{:x}", hasher.finalize()) } // -- Git helpers -- fn git_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .current_dir(repo_root) .args(args) .output() .context("failed to run git")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git {} failed: {}", args.join(" "), stderr.trim()); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } struct CommitInfo { sha: String, short_sha: String, message: String, subject: String, author: String, date: String, diff: String, files: Vec<String>, } fn get_commit_info(repo_root: &Path, sha: &str) -> Result<CommitInfo> { let short_sha = &sha[..7.min(sha.len())]; let message = git_capture_in(repo_root, &["log", "-1", "--format=%B", sha])? .trim() .to_string(); let subject = git_capture_in(repo_root, &["log", "-1", "--format=%s", sha])? .trim() .to_string(); let author = git_capture_in(repo_root, &["log", "-1", "--format=%an", sha])? .trim() .to_string(); let raw_date = git_capture_in( repo_root, &["log", "-1", "--date=format:%Y-%m-%d", "--format=%ad", sha], )? .trim() .to_string(); let date = if raw_date.len() == 10 && raw_date.as_bytes().get(4) == Some(&b'-') && raw_date.as_bytes().get(7) == Some(&b'-') { raw_date } else { Utc::now().format("%Y-%m-%d").to_string() }; let diff_full = git_capture_in(repo_root, &["diff", &format!("{}~1", sha), sha]).unwrap_or_default(); let diff = if diff_full.len() > MAX_DIFF_CHARS { format!( "{}\n\n... (truncated, {} total chars)", &diff_full[..MAX_DIFF_CHARS], diff_full.len() ) } else { diff_full }; let files_raw = git_capture_in( repo_root, &["diff", "--name-only", &format!("{}~1", sha), sha], ) .unwrap_or_default(); let files: Vec<String> = files_raw .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect(); Ok(CommitInfo { sha: sha.to_string(), short_sha: short_sha.to_string(), message, subject, author, date, diff, files, }) } fn get_commits_in_range(repo_root: &Path, from: &str, to: &str) -> Result<Vec<String>> { let range = format!("{}..{}", from, to); let output = git_capture_in(repo_root, &["rev-list", "--reverse", &range])?; Ok(output .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect()) } fn get_last_n_commits(repo_root: &Path, n: usize) -> Result<Vec<String>> { let n_str = format!("{}", n); let output = git_capture_in(repo_root, &["rev-list", "--reverse", "-n", &n_str, "HEAD"])?; Ok(output .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect()) } // -- AI explanation -- fn call_ai_explain(info: &CommitInfo, provider: &str, model: &str) -> Result<String> { let script = shellexpand::tilde(AI_TASK_SCRIPT).to_string(); let prompt = format!( "Explain this git commit concisely. Give a 1-2 sentence summary, then explain what changed and why.\n\n\ Commit: {}\nMessage: {}\n\nDiff:\n{}", info.short_sha, info.message, info.diff ); let output = Command::new(&script) .args([ "--agent", "explain", "--provider", provider, "--model", model, "--prompt", &prompt, "--max-steps", "5", ]) .output(); match output { Ok(out) if out.status.success() => { let text = String::from_utf8_lossy(&out.stdout).trim().to_string(); if text.is_empty() { Ok("(AI returned empty response)".to_string()) } else { Ok(text) } } Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); bail!("ai-task.sh failed: {}", stderr.trim()); } Err(e) => { bail!("failed to run ai-task.sh: {e}"); } } } // -- Markdown output -- fn slugify(s: &str) -> String { s.chars() .map(|c| { if c.is_alphanumeric() || c == '-' { c.to_ascii_lowercase() } else if c == ' ' || c == '_' || c == '/' { '-' } else { '\0' } }) .filter(|c| *c != '\0') .collect::<String>() .split('-') .filter(|s| !s.is_empty()) .collect::<Vec<_>>() .join("-") } fn write_commit_markdown( output_dir: &Path, info: &CommitInfo, ai_explanation: &str, generated_at: &str, ) -> Result<String> { let slug = slugify(&info.subject); let slug = if slug.len() > 60 { slug[..60].trim_end_matches('-').to_string() } else { slug }; let filename = format!("{}-{}-{}.md", info.date, info.short_sha, slug); let filepath = output_dir.join(&filename); // Parse AI explanation into summary and details let (summary, details) = split_ai_response(ai_explanation); let files_section = info .files .iter() .map(|f| format!("- {}", f)) .collect::<Vec<_>>() .join("\n"); let content = format!( "# {subject}\n\n\ **Commit**: `{sha}` | **Date**: {date} | **Author**: {author}\n\n\ ## Summary\n{summary}\n\n\ ## Changes\n{details}\n\n\ ## Files\n{files}\n", subject = info.subject, sha = info.short_sha, date = info.date, author = info.author, summary = &summary, details = &details, files = files_section, ); fs::write(&filepath, &content) .with_context(|| format!("failed to write {}", filepath.display()))?; let sidecar_file = filename.replacen(".md", ".json", 1); let sidecar_path = output_dir.join(&sidecar_file); let sidecar = ExplainedCommit { sha: info.sha.clone(), short_sha: info.short_sha.clone(), subject: info.subject.clone(), author: info.author.clone(), date: info.date.clone(), summary, changes: details, files: info.files.clone(), markdown_file: filename.clone(), generated_at: generated_at.to_string(), }; let sidecar_json = serde_json::to_string_pretty(&sidecar)?; fs::write(&sidecar_path, sidecar_json) .with_context(|| format!("failed to write {}", sidecar_path.display()))?; Ok(filename) } fn split_ai_response(text: &str) -> (String, String) { // Try to split on first blank line — first paragraph is summary, rest is details let trimmed = text.trim(); if let Some(pos) = trimmed.find("\n\n") { let summary = trimmed[..pos].trim().to_string(); let details = trimmed[pos..].trim().to_string(); (summary, details) } else { (trimmed.to_string(), trimmed.to_string()) } } fn resolve_output_dir_name(repo_root: &Path, output_dir_override: Option<&Path>) -> String { if let Some(path) = output_dir_override { return path.display().to_string(); } let cfg = load_explain_config(repo_root); cfg.as_ref() .and_then(|c| c.output_dir.clone()) .unwrap_or_else(|| DEFAULT_OUTPUT_DIR.to_string()) } fn resolve_output_dir(repo_root: &Path, output_dir_override: Option<&Path>) -> PathBuf { if let Some(path) = output_dir_override { if path.is_absolute() { return path.to_path_buf(); } return repo_root.join(path); } repo_root.join(resolve_output_dir_name(repo_root, None)) } fn resolve_explain_target(_repo_root: &Path) -> (String, String) { // Kimi is enforced for commit explanations to keep output quality predictable. (DEFAULT_PROVIDER.to_string(), DEFAULT_MODEL.to_string()) } fn short_sha_from_sha(sha: &str) -> String { sha[..7.min(sha.len())].to_string() } fn short_sha_from_filename(file: &str) -> String { // Filename convention: YYYY-MM-DD-<short_sha>-<slug>.md if file.len() > 11 { let rest = &file[11..]; if let Some(short_sha) = rest.split('-').next() { return short_sha.trim().to_string(); } } String::new() } fn extract_markdown_section(content: &str, heading: &str) -> String { let marker = format!("## {heading}"); let Some(start) = content.find(&marker) else { return String::new(); }; let section_start = start + marker.len(); let mut tail = &content[section_start..]; if let Some(stripped) = tail.strip_prefix('\n') { tail = stripped; } if let Some(stripped) = tail.strip_prefix('\r') { tail = stripped; } if let Some(next) = tail.find("\n## ") { tail[..next].trim().to_string() } else { tail.trim().to_string() } } fn parse_markdown_metadata(content: &str) -> (String, String, String, String) { let subject = content .lines() .find_map(|line| line.strip_prefix("# ").map(str::trim)) .unwrap_or_default() .to_string(); let mut sha = String::new(); let mut date = String::new(); let mut author = String::new(); if let Some(meta) = content.lines().find(|line| line.starts_with("**Commit**:")) { for part in meta.split('|').map(str::trim) { if part.starts_with("**Commit**:") { sha = part .split('`') .nth(1) .unwrap_or_default() .trim() .to_string(); } else if part.starts_with("**Date**:") { date = part.trim_start_matches("**Date**:").trim().to_string(); } else if part.starts_with("**Author**:") { author = part.trim_start_matches("**Author**:").trim().to_string(); } } } (subject, sha, date, author) } fn read_explained_commit( output_dir: &Path, short_sha_key: &str, entry: &CommitEntry, ) -> Result<Option<ExplainedCommit>> { let sidecar_file = entry.file.replacen(".md", ".json", 1); let sidecar_path = output_dir.join(&sidecar_file); if sidecar_path.exists() { let json = fs::read_to_string(&sidecar_path) .with_context(|| format!("failed to read {}", sidecar_path.display()))?; let mut commit: ExplainedCommit = serde_json::from_str(&json) .with_context(|| format!("failed to parse {}", sidecar_path.display()))?; if commit.markdown_file.is_empty() { commit.markdown_file = entry.file.clone(); } if commit.generated_at.is_empty() { commit.generated_at = entry.at.clone(); } if commit.short_sha.is_empty() { commit.short_sha = short_sha_key.to_string(); } return Ok(Some(commit)); } let markdown_path = output_dir.join(&entry.file); if !markdown_path.exists() { return Ok(None); } let content = fs::read_to_string(&markdown_path) .with_context(|| format!("failed to read {}", markdown_path.display()))?; let (subject, parsed_sha, parsed_date, parsed_author) = parse_markdown_metadata(&content); let summary = extract_markdown_section(&content, "Summary"); let changes = extract_markdown_section(&content, "Changes"); let files = extract_markdown_section(&content, "Files") .lines() .map(str::trim) .filter_map(|line| line.strip_prefix("- ").map(str::to_string)) .collect::<Vec<_>>(); let short_sha = if !short_sha_key.is_empty() { short_sha_key.to_string() } else if !entry.sha.is_empty() { short_sha_from_sha(&entry.sha) } else { short_sha_from_filename(&entry.file) }; let fallback_sha = if !entry.sha.is_empty() { entry.sha.clone() } else if !parsed_sha.is_empty() { parsed_sha.clone() } else { short_sha.clone() }; Ok(Some(ExplainedCommit { sha: fallback_sha, short_sha, subject, author: parsed_author, date: parsed_date, summary, changes, files, markdown_file: entry.file.clone(), generated_at: entry.at.clone(), })) } // -- Core functions -- fn load_explain_config(repo_root: &Path) -> Option<config::ExplainCommitsConfig> { let flow_toml = repo_root.join("flow.toml"); if !flow_toml.exists() { return None; } let cfg = config::load_or_default(&flow_toml); cfg.explain_commits } fn maybe_register_project(repo_root: &Path) { let flow_toml = repo_root.join("flow.toml"); if !flow_toml.exists() { return; } let cfg = config::load_or_default(&flow_toml); if let Some(name) = cfg.project_name.as_deref() { let _ = projects::register_project(name, &flow_toml); } } /// Read explained commits for a project, newest first. pub fn list_explained_commits( repo_root: &Path, limit: Option<usize>, ) -> Result<Vec<ExplainedCommit>> { let output_dir = resolve_output_dir(repo_root, None); if !output_dir.exists() { return Ok(Vec::new()); } let index = load_index(&output_dir); let mut indexed_entries = index.commits.into_iter().collect::<Vec<_>>(); indexed_entries.sort_by(|(_, left), (_, right)| right.at.cmp(&left.at)); let mut commits = Vec::new(); for (short_sha_key, entry) in indexed_entries { if let Some(commit) = read_explained_commit(&output_dir, &short_sha_key, &entry)? { commits.push(commit); if let Some(max_items) = limit && commits.len() >= max_items { break; } } } Ok(commits) } /// Read one explained commit by SHA (full or prefix). pub fn get_explained_commit(repo_root: &Path, sha: &str) -> Result<Option<ExplainedCommit>> { let trimmed = sha.trim(); if trimmed.is_empty() { return Ok(None); } let commits = list_explained_commits(repo_root, None)?; let mut prefix_match: Option<ExplainedCommit> = None; for commit in commits { if commit.sha.eq_ignore_ascii_case(trimmed) || commit.short_sha.eq_ignore_ascii_case(trimmed) { return Ok(Some(commit)); } if commit.sha.starts_with(trimmed) || commit.short_sha.starts_with(trimmed) { if prefix_match.is_none() { prefix_match = Some(commit); } else { // Ambiguous prefix. return Ok(None); } } } Ok(prefix_match) } /// Explain new commits since `head_before` (used by post-sync hook). pub fn explain_new_commits_since(repo_root: &Path, head_before: &str) -> Result<()> { let head_after = git_capture_in(repo_root, &["rev-parse", "HEAD"])? .trim() .to_string(); if head_before == head_after { return Ok(()); } let cfg = load_explain_config(repo_root); let output_dir_name = resolve_output_dir_name(repo_root, None); let batch_size = cfg .as_ref() .and_then(|c| c.batch_size) .unwrap_or(DEFAULT_BATCH_SIZE); let output_dir = resolve_output_dir(repo_root, None); fs::create_dir_all(&output_dir)?; let (provider, model) = resolve_explain_target(repo_root); let commits = get_commits_in_range(repo_root, head_before, &head_after)?; if commits.is_empty() { return Ok(()); } let to_process = if commits.len() > batch_size { println!( " {} new commits, processing last {} (batch limit)", commits.len(), batch_size ); &commits[commits.len() - batch_size..] } else { &commits }; let mut index = load_index(&output_dir); let mut explained = 0; for sha in to_process { let info = match get_commit_info(repo_root, sha) { Ok(info) => info, Err(e) => { eprintln!(" warn: skipping {}: {e}", &sha[..7.min(sha.len())]); continue; } }; let digest = compute_digest(&info.sha, &info.message, &info.diff); // Skip if already processed with same digest if let Some(entry) = index.commits.get(&info.short_sha) && entry.digest == digest { continue; } println!(" explaining {} {}", info.short_sha, info.subject); let explanation = match call_ai_explain(&info, &provider, &model) { Ok(text) => text, Err(e) => { eprintln!(" warn: AI failed for {}: {e}", info.short_sha); continue; } }; let generated_at = Utc::now().to_rfc3339(); let filename = write_commit_markdown(&output_dir, &info, &explanation, &generated_at)?; index.commits.insert( info.short_sha.clone(), CommitEntry { digest, file: filename, at: generated_at, sha: info.sha, }, ); explained += 1; } if explained > 0 { save_index(&output_dir, &index)?; println!(" explained {explained} commit(s) → {output_dir_name}/"); } Ok(()) } /// Explain last N commits (CLI entry point). pub fn explain_last_n_commits( repo_root: &Path, n: usize, force: bool, output_dir_override: Option<&Path>, ) -> Result<()> { let output_dir_name = resolve_output_dir_name(repo_root, output_dir_override); let output_dir = resolve_output_dir(repo_root, output_dir_override); fs::create_dir_all(&output_dir)?; let (provider, model) = resolve_explain_target(repo_root); println!("using provider={provider} model={model}"); let commits = get_last_n_commits(repo_root, n)?; if commits.is_empty() { println!("No commits found."); return Ok(()); } let mut index = load_index(&output_dir); let mut explained = 0; let mut skipped = 0; for sha in &commits { let info = match get_commit_info(repo_root, sha) { Ok(info) => info, Err(e) => { eprintln!("warn: skipping {}: {e}", &sha[..7.min(sha.len())]); continue; } }; let digest = compute_digest(&info.sha, &info.message, &info.diff); // Skip if already processed with same digest (unless --force) if !force { if let Some(entry) = index.commits.get(&info.short_sha) && entry.digest == digest { skipped += 1; continue; } } println!("explaining {} {}", info.short_sha, info.subject); let explanation = match call_ai_explain(&info, &provider, &model) { Ok(text) => text, Err(e) => { eprintln!("warn: AI failed for {}: {e}", info.short_sha); continue; } }; let generated_at = Utc::now().to_rfc3339(); let filename = write_commit_markdown(&output_dir, &info, &explanation, &generated_at)?; index.commits.insert( info.short_sha.clone(), CommitEntry { digest, file: filename, at: generated_at, sha: info.sha, }, ); explained += 1; } save_index(&output_dir, &index)?; if explained > 0 { println!("explained {explained} commit(s) → {output_dir_name}/"); } if skipped > 0 { println!("skipped {skipped} already-processed commit(s)"); } if explained == 0 && skipped == 0 { println!("no commits to explain"); } Ok(()) } /// Called after sync — checks config and explains new commits. Non-fatal. pub fn maybe_run_after_sync(repo_root: &Path, head_before: &str) -> Result<()> { maybe_register_project(repo_root); let cfg = load_explain_config(repo_root); let enabled = cfg.as_ref().and_then(|c| c.enabled).unwrap_or(false); if !enabled { return Ok(()); } explain_new_commits_since(repo_root, head_before) } /// CLI entry point for `f explain-commits`. pub fn run_cli(cmd: ExplainCommitsCommand) -> Result<()> { let repo_root = std::env::current_dir()?; // Verify we're in a git repo git_capture_in(&repo_root, &["rev-parse", "--git-dir"])?; maybe_register_project(&repo_root); let n = cmd.count.unwrap_or(1); explain_last_n_commits(&repo_root, n, cmd.force, cmd.out_dir.as_deref()) } ================================================ FILE: src/ext.rs ================================================ use std::fs; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::cli::ExtCommand; use crate::code; use crate::config; use crate::setup::add_gitignore_entry; pub fn run(cmd: ExtCommand) -> Result<()> { let source = normalize_path(&cmd.path)?; if !source.exists() { bail!("Path not found: {}", source.display()); } if !source.is_dir() { bail!("Path must be a directory: {}", source.display()); } let project_root = project_root_from_cwd(); let ext_dir = project_root.join("ext"); fs::create_dir_all(&ext_dir)?; let name = source .file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "external".to_string()); let dest = ext_dir.join(&name); if dest.exists() { bail!("Destination already exists: {}", dest.display()); } let source_workspace = prepare_source_workspace(&source, &project_root)?; copy_dir_all(&source_workspace, &dest)?; add_gitignore_entry(&project_root, "ext/")?; if let Err(err) = code::migrate_sessions_between_paths(&source, &dest, false, false, false) { eprintln!("WARN failed to migrate sessions: {err}"); } println!( "Copied {} -> {}", source_workspace.display(), dest.display() ); Ok(()) } fn normalize_path(path: &str) -> Result<PathBuf> { let expanded = config::expand_path(path); let canonical = expanded.canonicalize().unwrap_or(expanded); Ok(canonical) } fn project_root_from_cwd() -> PathBuf { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut current = cwd.clone(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return current; } if !current.pop() { return cwd; } } } fn copy_dir_all(from: &Path, to: &Path) -> Result<()> { fs::create_dir_all(to).with_context(|| format!("failed to create {}", to.display()))?; for entry in fs::read_dir(from).with_context(|| format!("failed to read {}", from.display()))? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; let target = to.join(entry.file_name()); if target.exists() { bail!("Refusing to overwrite {}", target.display()); } if file_type.is_dir() { copy_dir_all(&path, &target)?; } else if file_type.is_file() { fs::copy(&path, &target) .with_context(|| format!("failed to copy {}", path.display()))?; } else if file_type.is_symlink() { let link_target = fs::read_link(&path) .with_context(|| format!("failed to read link {}", path.display()))?; copy_symlink(&link_target, &target)?; } } Ok(()) } fn copy_symlink(target: &Path, dest: &Path) -> Result<()> { #[cfg(unix)] { std::os::unix::fs::symlink(target, dest) .with_context(|| format!("failed to create symlink {}", dest.display()))?; return Ok(()); } #[cfg(not(unix))] { let metadata = fs::metadata(target).with_context(|| format!("failed to read {}", target.display()))?; if metadata.is_dir() { copy_dir_all(target, dest)?; } else { fs::copy(target, dest) .with_context(|| format!("failed to copy {}", target.display()))?; } Ok(()) } } fn prepare_source_workspace(source: &Path, project_root: &Path) -> Result<PathBuf> { let repo_root = match jj_root(source) { Ok(root) => root, Err(_) => { bail!( "Source is not a jj workspace. Run `jj git init --colocate` in {} and retry.", source.display() ); } }; let workspace = workspace_name_for_project(project_root)?; if workspace.is_empty() { return Ok(source.to_path_buf()); } let status = git_capture_in(&repo_root, &["status", "--porcelain"]).unwrap_or_default(); if !status.trim().is_empty() { println!("Source repo has uncommitted changes:"); for line in status.lines().take(20) { println!(" {line}"); } let continue_anyway = prompt_yes_no( &format!("Continue and use jj workspace \"{}\"?", workspace), false, )?; if !continue_anyway { bail!("Aborted; commit or stash changes before continuing."); } } let workspaces = jj_workspace_list(&repo_root).unwrap_or_default(); if let Some(existing_path) = workspaces.get(&workspace) { return Ok(PathBuf::from(existing_path)); } let base = workspace_base(&repo_root)?; fs::create_dir_all(&base).with_context(|| format!("failed to create {}", base.display()))?; let workspace_path = base.join(&workspace); jj_run_in( &repo_root, &[ "workspace", "add", workspace_path .to_str() .ok_or_else(|| anyhow::anyhow!("invalid workspace path"))?, "--name", &workspace, ], )?; println!( "Created jj workspace {} at {}", workspace, workspace_path.display() ); Ok(workspace_path) } fn workspace_name_for_project(project_root: &Path) -> Result<String> { let home = std::env::var("HOME").ok(); let mut relative = None; if let Some(home) = home.as_deref() { if let Ok(stripped) = project_root.strip_prefix(home) { relative = Some(stripped.to_path_buf()); } } let name = if let Some(rel) = relative { rel.to_string_lossy().trim_start_matches('/').to_string() } else { project_root .file_name() .and_then(|n| n.to_str()) .unwrap_or("external") .to_string() }; let mut sanitized = String::new(); for ch in name.chars() { if ch.is_ascii_alphanumeric() || ch == '/' || ch == '.' || ch == '-' || ch == '_' { sanitized.push(ch); } else { sanitized.push('-'); } } Ok(sanitized.trim_matches('/').to_string()) } fn workspace_base(repo_root: &Path) -> Result<PathBuf> { let home = std::env::var("HOME").context("HOME not set")?; let repo_name = repo_root .file_name() .and_then(|n| n.to_str()) .unwrap_or("repo"); Ok(PathBuf::from(home) .join(".jj") .join("workspaces") .join(repo_name)) } fn jj_root(source: &Path) -> Result<PathBuf> { let root = jj_capture_in(source, &["root"])?; Ok(PathBuf::from(root.trim())) } fn jj_workspace_list(repo_root: &Path) -> Result<std::collections::HashMap<String, String>> { let output = jj_capture_in(repo_root, &["workspace", "list"])?; let mut map = std::collections::HashMap::new(); for line in output.lines() { let line = line.trim().trim_start_matches('*').trim(); let Some((name, path)) = line.split_once(':') else { continue; }; let name = name.trim().to_string(); let path = path.trim().to_string(); if !name.is_empty() && !path.is_empty() { map.insert(name, path); } } Ok(map) } fn jj_run_in(repo_root: &Path, args: &[&str]) -> Result<()> { let output = Command::new("jj") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() { print!("{}", stdout); } let stderr = String::from_utf8_lossy(&output.stderr); for line in stderr.lines() { if line.contains("Refused to snapshot") { continue; } eprintln!("{}", line); } if !output.status.success() { bail!("jj {} failed", args.join(" ")); } Ok(()) } fn jj_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("jj") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; if !output.status.success() { bail!("jj {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn git_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { bail!("git {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> { let prompt = if default_yes { "[Y/n]" } else { "[y/N]" }; print!("{message} {prompt}: "); io::stdout().flush()?; if !io::stdin().is_terminal() { bail!("Non-interactive session; cannot confirm action."); } let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_yes); } Ok(answer == "y" || answer == "yes") } ================================================ FILE: src/features.rs ================================================ //! Feature registry: read/write `.ai/features/*.md` files with YAML frontmatter. //! //! Features are committed project knowledge describing what capabilities exist, //! which files implement them, and whether they have docs/tests. use anyhow::{Context, Result}; use std::collections::HashMap; use std::path::{Path, PathBuf}; /// Parsed feature file from `.ai/features/<name>.md`. #[derive(Debug, Clone)] pub struct FeatureEntry { pub name: String, pub description: String, pub status: String, pub files: Vec<String>, pub tests: Vec<String>, pub coverage: String, pub added_in: String, pub last_verified: String, pub created_at: String, pub updated_at: String, /// Markdown body after the frontmatter. pub content: String, } /// A feature whose tracked files overlap with the current diff. #[derive(Debug)] pub struct StaleFeature { pub name: String, pub stale_files: Vec<String>, } /// Return the `.ai/features/` directory for a project. fn features_dir(project_root: &Path) -> PathBuf { project_root.join(".ai").join("features") } /// List all feature names in `.ai/features/`. pub fn list_features(project_root: &Path) -> Result<Vec<String>> { let dir = features_dir(project_root); if !dir.exists() { return Ok(Vec::new()); } let mut names = Vec::new(); for entry in std::fs::read_dir(&dir).context("reading .ai/features/")? { let entry = entry?; let path = entry.path(); if path.extension().map_or(false, |e| e == "md") { if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { names.push(stem.to_string()); } } } names.sort(); Ok(names) } /// Load a single feature file and parse its YAML frontmatter. pub fn load_feature(path: &Path) -> Result<FeatureEntry> { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading feature file {}", path.display()))?; parse_feature_file(&raw, path) } /// Load all features from `.ai/features/`. pub fn load_all_features(project_root: &Path) -> Result<Vec<FeatureEntry>> { let dir = features_dir(project_root); if !dir.exists() { return Ok(Vec::new()); } let mut features = Vec::new(); for entry in std::fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); if path.extension().map_or(false, |e| e == "md") { match load_feature(&path) { Ok(f) => features.push(f), Err(e) => eprintln!("warning: skipping {}: {}", path.display(), e), } } } Ok(features) } /// Scan `.ai/features/` and identify which are stale relative to the current diff. pub fn scan_features(project_root: &Path, changed_files: &[String]) -> Vec<StaleFeature> { let features = match load_all_features(project_root) { Ok(f) => f, Err(_) => return Vec::new(), }; let changed_set: std::collections::HashSet<&str> = changed_files.iter().map(|s| s.as_str()).collect(); let mut stale = Vec::new(); for feat in &features { let overlap: Vec<String> = feat .files .iter() .filter(|f| changed_set.contains(f.as_str())) .cloned() .collect(); if !overlap.is_empty() { stale.push(StaleFeature { name: feat.name.clone(), stale_files: overlap, }); } } stale } /// Write/update a feature file with YAML frontmatter + markdown body. pub fn save_feature(project_root: &Path, entry: &FeatureEntry) -> Result<()> { let dir = features_dir(project_root); std::fs::create_dir_all(&dir).context("creating .ai/features/")?; let path = dir.join(format!("{}.md", entry.name)); let content = render_feature_file(entry); std::fs::write(&path, content) .with_context(|| format!("writing feature file {}", path.display()))?; Ok(()) } /// Update the `last_verified` field of an existing feature. pub fn update_feature_verified(project_root: &Path, name: &str, commit_sha: &str) -> Result<()> { let path = features_dir(project_root).join(format!("{}.md", name)); if !path.exists() { return Ok(()); } let mut entry = load_feature(&path)?; entry.last_verified = commit_sha.to_string(); entry.updated_at = chrono_now(); save_feature(project_root, &entry) } /// Update the test files list for an existing feature. pub fn update_feature_tests(project_root: &Path, name: &str, test_files: &[String]) -> Result<()> { let path = features_dir(project_root).join(format!("{}.md", name)); if !path.exists() { return Ok(()); } let mut entry = load_feature(&path)?; // Merge new test files for tf in test_files { if !entry.tests.contains(tf) { entry.tests.push(tf.clone()); } } if !test_files.is_empty() && entry.coverage == "none" { entry.coverage = "partial".to_string(); } entry.updated_at = chrono_now(); save_feature(project_root, &entry) } /// Apply quality results from the AI review: create new feature docs, update existing ones. /// Returns a list of action descriptions (e.g., "created foo.md"). pub(crate) fn apply_quality_results( project_root: &Path, quality: &crate::commit::QualityResult, commit_sha: &str, ) -> Result<Vec<String>> { let mut actions = Vec::new(); let now = chrono_now(); // 1. Write new feature docs for new_feat in &quality.new_features { let entry = FeatureEntry { name: new_feat.name.clone(), description: new_feat.description.clone(), status: "active".to_string(), files: new_feat.files.clone(), tests: Vec::new(), coverage: "none".to_string(), added_in: commit_sha.to_string(), last_verified: commit_sha.to_string(), created_at: now.clone(), updated_at: now.clone(), content: new_feat.doc_content.clone(), }; save_feature(project_root, &entry)?; actions.push(format!("created {}.md", new_feat.name)); } // 2. Update last_verified for touched features that are current for touched in &quality.features_touched { if touched.doc_current { update_feature_verified(project_root, &touched.name, commit_sha)?; } if touched.has_tests { update_feature_tests(project_root, &touched.name, &touched.test_files)?; } } Ok(actions) } /// Build context about existing features for the AI review prompt. pub fn features_context_for_review(project_root: &Path, changed_files: &[String]) -> String { let features = match load_all_features(project_root) { Ok(f) => f, Err(_) => return String::new(), }; if features.is_empty() { return String::new(); } let stale = scan_features(project_root, changed_files); let stale_names: std::collections::HashSet<&str> = stale.iter().map(|s| s.name.as_str()).collect(); let mut ctx = String::from("\nExisting documented features in .ai/features/:\n"); for feat in &features { let stale_marker = if stale_names.contains(feat.name.as_str()) { " [STALE - files changed in this diff]" } else { "" }; ctx.push_str(&format!( "- {} ({}): {}{}\n", feat.name, feat.status, feat.description, stale_marker )); } ctx } // ── Internal helpers ──────────────────────────────────────────────── fn chrono_now() -> String { // Simple ISO 8601 without chrono dependency let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); // Format as basic ISO-ish timestamp format!("{}Z", now) } fn parse_feature_file(raw: &str, path: &Path) -> Result<FeatureEntry> { // Split frontmatter from content let (frontmatter, body) = if raw.starts_with("---\n") || raw.starts_with("---\r\n") { let after_open = if raw.starts_with("---\r\n") { 5 } else { 4 }; if let Some(end) = raw[after_open..].find("\n---") { let fm_end = after_open + end; let body_start = fm_end + 4; // skip \n--- let body_start = if raw[body_start..].starts_with('\n') { body_start + 1 } else if raw[body_start..].starts_with("\r\n") { body_start + 2 } else { body_start }; ( &raw[after_open..fm_end], raw[body_start..].trim().to_string(), ) } else { ("", raw.to_string()) } } else { ("", raw.to_string()) }; let fm = parse_yaml_frontmatter(frontmatter); let name = fm .get("name") .cloned() .or_else(|| { path.file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_string()) }) .unwrap_or_default(); Ok(FeatureEntry { name, description: fm.get("description").cloned().unwrap_or_default(), status: fm .get("status") .cloned() .unwrap_or_else(|| "active".to_string()), files: parse_yaml_list(fm.get("files").map(|s| s.as_str()).unwrap_or("")), tests: parse_yaml_list(fm.get("tests").map(|s| s.as_str()).unwrap_or("")), coverage: fm .get("coverage") .cloned() .unwrap_or_else(|| "none".to_string()), added_in: fm.get("added_in").cloned().unwrap_or_default(), last_verified: fm.get("last_verified").cloned().unwrap_or_default(), created_at: fm.get("created_at").cloned().unwrap_or_default(), updated_at: fm.get("updated_at").cloned().unwrap_or_default(), content: body, }) } /// Minimal YAML frontmatter parser for key: value pairs. fn parse_yaml_frontmatter(fm: &str) -> HashMap<String, String> { let mut map = HashMap::new(); let mut current_key = String::new(); let mut in_list = false; let mut list_items = Vec::new(); for line in fm.lines() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } // Check if this is a list item if trimmed.starts_with("- ") && in_list { let value = trimmed[2..].trim().to_string(); list_items.push(value); continue; } // If we were building a list, save it if in_list && !list_items.is_empty() { map.insert(current_key.clone(), list_items.join("\n")); list_items.clear(); in_list = false; } // Parse key: value if let Some(colon_pos) = trimmed.find(':') { let key = trimmed[..colon_pos].trim().to_string(); let value = trimmed[colon_pos + 1..].trim().to_string(); if value.is_empty() { // This might be the start of a list current_key = key; in_list = true; } else { map.insert(key, value); } } } // Save any trailing list if in_list && !list_items.is_empty() { map.insert(current_key, list_items.join("\n")); } map } /// Parse a YAML list stored as newline-separated values. fn parse_yaml_list(raw: &str) -> Vec<String> { if raw.is_empty() { return Vec::new(); } raw.lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect() } fn render_feature_file(entry: &FeatureEntry) -> String { let mut out = String::from("---\n"); out.push_str(&format!("name: {}\n", entry.name)); out.push_str(&format!("description: {}\n", entry.description)); out.push_str(&format!("status: {}\n", entry.status)); if !entry.files.is_empty() { out.push_str("files:\n"); for f in &entry.files { out.push_str(&format!(" - {}\n", f)); } } else { out.push_str("files:\n"); } if !entry.tests.is_empty() { out.push_str("tests:\n"); for t in &entry.tests { out.push_str(&format!(" - {}\n", t)); } } else { out.push_str("tests:\n"); } out.push_str(&format!("coverage: {}\n", entry.coverage)); out.push_str(&format!("added_in: {}\n", entry.added_in)); out.push_str(&format!("last_verified: {}\n", entry.last_verified)); out.push_str(&format!("created_at: {}\n", entry.created_at)); out.push_str(&format!("updated_at: {}\n", entry.updated_at)); out.push_str("---\n\n"); out.push_str(&entry.content); if !entry.content.ends_with('\n') { out.push('\n'); } out } ================================================ FILE: src/fish_install.rs ================================================ use std::env; use std::fs; use std::io::{self, IsTerminal, Write}; use std::path::PathBuf; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::cli::FishInstallOpts; use crate::fish_trace; pub fn run(opts: FishInstallOpts) -> Result<()> { let bin_dir = opts.bin_dir.unwrap_or_else(default_bin_dir); let fish_bin = bin_dir.join("fish"); // Check if already installed if fish_bin.exists() && !opts.force { if is_traced_fish(&fish_bin)? { println!("Traced fish is already installed at {}", fish_bin.display()); println!("Use --force to reinstall."); return Ok(()); } if !opts.yes && !confirm_overwrite(&fish_bin)? { bail!("Aborted."); } } // Find fish source let source = match opts.source { Some(path) => { if !path.join("Cargo.toml").exists() { bail!( "No Cargo.toml found at {}. Is this the fish-shell repo?", path.display() ); } path } None => { let Some(path) = fish_trace::fish_source_path() else { bail!( "Could not find fish-shell source. Please specify --source or set FISH_SOURCE_PATH.\n\ Clone from: https://github.com/fish-shell/fish-shell" ); }; path } }; println!("Building traced fish from {}", source.display()); // Confirm before building if !opts.yes && io::stdin().is_terminal() { println!(); println!("This will:"); println!(" 1. Build fish shell with release optimizations"); println!(" 2. Install to {}", fish_bin.display()); println!(" 3. Enable always-on I/O tracing (near-zero overhead)"); println!(); if !confirm("Proceed?")? { bail!("Aborted."); } } // Build release println!("Running: cargo build --release --locked"); let status = Command::new("cargo") .args(["build", "--release", "--locked"]) .current_dir(&source) .status() .context("failed to run cargo build")?; if !status.success() { bail!("cargo build failed"); } // Install let built_bin = source.join("target/release/fish"); if !built_bin.exists() { bail!("Built binary not found at {}", built_bin.display()); } fs::create_dir_all(&bin_dir) .with_context(|| format!("failed to create {}", bin_dir.display()))?; fs::copy(&built_bin, &fish_bin) .with_context(|| format!("failed to copy to {}", fish_bin.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&fish_bin)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&fish_bin, perms)?; } println!(); println!("Installed traced fish to {}", fish_bin.display()); println!(); println!("To use it:"); println!(" exec {}", fish_bin.display()); println!(); println!("Or add {} to your PATH.", bin_dir.display()); println!(); println!("I/O tracing is enabled by default. View traces with:"); println!(" f fish-last # last command + output"); println!(" f fish-last-full # full details"); println!(" f last-cmd # (same as fish-last when traced fish is active)"); if !path_in_env(&bin_dir) { println!(); println!("Note: {} is not in your PATH.", bin_dir.display()); println!("Add it with: fish_add_path {}", bin_dir.display()); } Ok(()) } fn default_bin_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".local") .join("bin") } fn is_traced_fish(path: &PathBuf) -> Result<bool> { // Check if the fish binary has our tracing markers let output = Command::new(path) .args(["-c", "echo $fish_io_trace"]) .output(); // If it runs without error, it might be our fork // A more reliable check would be to look for specific version strings match output { Ok(out) => { // Our traced fish defaults to "metadata" mode let stdout = String::from_utf8_lossy(&out.stdout); // Check if fish_io_trace variable exists (it's set by default in our fork) Ok(stdout.trim() == "metadata" || stdout.contains("metadata")) } Err(_) => Ok(false), } } fn confirm_overwrite(path: &PathBuf) -> Result<bool> { if !io::stdin().is_terminal() { return Ok(false); } print!("{} already exists. Overwrite? [y/N]: ", path.display()); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); Ok(answer == "y" || answer == "yes") } fn confirm(msg: &str) -> Result<bool> { if !io::stdin().is_terminal() { return Ok(true); } print!("{} [y/N]: ", msg); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); Ok(answer == "y" || answer == "yes") } fn path_in_env(bin_dir: &PathBuf) -> bool { let Ok(path) = env::var("PATH") else { return false; }; env::split_paths(&path).any(|entry| entry == *bin_dir) } ================================================ FILE: src/fish_trace.rs ================================================ use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, UNIX_EPOCH}; use anyhow::{Context, Result}; /// Fish shell trace record from io-trace metadata #[derive(Debug, Clone)] pub struct FishTraceRecord { pub version: u32, pub timestamp_secs: u64, pub job_id: u64, pub cwd: String, pub cmd: String, pub status: i32, pub pipestatus: Vec<i32>, pub stdout_len: usize, pub stderr_len: usize, pub stdout_truncated: bool, pub stderr_truncated: bool, } impl FishTraceRecord { pub fn timestamp_ms(&self) -> u64 { self.timestamp_secs * 1000 } pub fn formatted_time(&self) -> String { let system_time = UNIX_EPOCH + Duration::from_secs(self.timestamp_secs); let dt: chrono::DateTime<chrono::Local> = system_time.into(); dt.format("%H:%M:%S").to_string() } pub fn success(&self) -> bool { self.status == 0 } } /// Get the fish io-trace directory pub fn io_trace_dir() -> PathBuf { if let Ok(path) = env::var("FISH_IO_TRACE_DIR") { return PathBuf::from(path); } let data_home = env::var("XDG_DATA_HOME") .map(PathBuf::from) .unwrap_or_else(|_| { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".local") .join("share") }); data_home.join("fish").join("io-trace") } /// Load the last fish trace record pub fn load_last_record() -> Result<Option<FishTraceRecord>> { let dir = io_trace_dir(); let meta_path = dir.join("last.meta"); if !meta_path.exists() { return Ok(None); } parse_meta_file(&meta_path) } /// Load stdout from last fish trace pub fn load_last_stdout() -> Result<Option<String>> { let dir = io_trace_dir(); let path = dir.join("last.stdout"); if !path.exists() { return Ok(None); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; Ok(Some(content)) } /// Load stderr from last fish trace pub fn load_last_stderr() -> Result<Option<String>> { let dir = io_trace_dir(); let path = dir.join("last.stderr"); if !path.exists() { return Ok(None); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; Ok(Some(content)) } fn parse_meta_file(path: &Path) -> Result<Option<FishTraceRecord>> { let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; let mut record = FishTraceRecord { version: 1, timestamp_secs: 0, job_id: 0, cwd: String::new(), cmd: String::new(), status: 0, pipestatus: vec![], stdout_len: 0, stderr_len: 0, stdout_truncated: false, stderr_truncated: false, }; for line in content.lines() { let line = line.trim(); if line.is_empty() { continue; } let Some((key, value)) = line.split_once('=') else { continue; }; match key { "version" => record.version = value.parse().unwrap_or(1), "timestamp_secs" => record.timestamp_secs = value.parse().unwrap_or(0), "job_id" => record.job_id = value.parse().unwrap_or(0), "cwd" => record.cwd = value.to_string(), "cmd" => { // Remove surrounding quotes if present let v = value.trim(); if v.starts_with('"') && v.ends_with('"') && v.len() > 1 { record.cmd = v[1..v.len() - 1].to_string(); } else { record.cmd = v.to_string(); } } "status" => record.status = value.parse().unwrap_or(0), "pipestatus" => { record.pipestatus = value .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); } "stdout_len" => record.stdout_len = value.parse().unwrap_or(0), "stderr_len" => record.stderr_len = value.parse().unwrap_or(0), "stdout_truncated" => record.stdout_truncated = value != "0", "stderr_truncated" => record.stderr_truncated = value != "0", _ => {} } } if record.timestamp_secs == 0 { return Ok(None); } Ok(Some(record)) } /// Print the last fish shell command and its output (like `trail last`) pub fn print_last_fish_cmd() -> Result<()> { let Some(record) = load_last_record()? else { println!("No fish trace found at {}", io_trace_dir().display()); return Ok(()); }; println!("{}", record.cmd); let stdout = load_last_stdout()?.unwrap_or_default(); let stderr = load_last_stderr()?.unwrap_or_default(); if !stdout.is_empty() { print!("{stdout}"); if !stdout.ends_with('\n') { println!(); } } if !stderr.is_empty() { eprint!("{stderr}"); if !stderr.ends_with('\n') { eprintln!(); } } if stdout.is_empty() && stderr.is_empty() && !record.success() { println!("(exit status: {})", record.status); } Ok(()) } /// Print full details of the last fish shell command pub fn print_last_fish_cmd_full() -> Result<()> { let Some(record) = load_last_record()? else { println!("No fish trace found at {}", io_trace_dir().display()); return Ok(()); }; println!("cmd: {}", record.cmd); println!("cwd: {}", record.cwd); println!("job_id: {}", record.job_id); println!("timestamp: {}", record.formatted_time()); println!( "status: {} (code: {})", if record.success() { "success" } else { "failure" }, record.status ); if !record.pipestatus.is_empty() { let ps: Vec<String> = record.pipestatus.iter().map(|s| s.to_string()).collect(); println!("pipestatus: {}", ps.join(",")); } println!( "stdout: {} bytes{}", record.stdout_len, if record.stdout_truncated { " (truncated)" } else { "" } ); println!( "stderr: {} bytes{}", record.stderr_len, if record.stderr_truncated { " (truncated)" } else { "" } ); let stdout = load_last_stdout()?.unwrap_or_default(); let stderr = load_last_stderr()?.unwrap_or_default(); if !stdout.is_empty() || !stderr.is_empty() { println!("--- output ---"); } if !stdout.is_empty() { print!("{stdout}"); if !stdout.ends_with('\n') { println!(); } } if !stderr.is_empty() { eprintln!("--- stderr ---"); eprint!("{stderr}"); } Ok(()) } /// Check if traced fish shell is installed pub fn is_traced_fish_installed() -> bool { let bin_path = traced_fish_bin_path(); bin_path.exists() } /// Get the path to the traced fish binary pub fn traced_fish_bin_path() -> PathBuf { if let Ok(path) = env::var("FISH_TRACED_BIN") { return PathBuf::from(path); } dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".local") .join("bin") .join("fish") } /// Get the path to fish-shell source repo (for building) pub fn fish_source_path() -> Option<PathBuf> { // Check env var first if let Ok(path) = env::var("FISH_SOURCE_PATH") { let p = PathBuf::from(path); if p.exists() { return Some(p); } } // Check common locations let home = dirs::home_dir()?; let candidates = [ home.join("repos/fish-shell/fish-shell"), home.join("code/fish-shell"), home.join(".local/src/fish-shell"), ]; for candidate in candidates { if candidate.join("Cargo.toml").exists() { return Some(candidate); } } None } ================================================ FILE: src/fix.rs ================================================ use std::fs; use std::io::{self, IsTerminal, Read, Write}; use std::path::PathBuf; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use crate::cli::FixOpts; use crate::opentui_prompt; pub fn run(opts: FixOpts) -> Result<()> { let message = resolve_fix_message(&opts.message)?; let repo_root = git_top_level()?; if try_run_commit_repair(&repo_root, &message)? { return Ok(()); } let unroll = !opts.no_unroll; let mut stashed = false; if unroll { ensure_clean_or_stash(&repo_root, opts.stash, &mut stashed)?; ensure_has_parent_commit(&repo_root)?; let head = git_output(&repo_root, &["rev-parse", "HEAD"])?; let head_short = head.trim().chars().take(7).collect::<String>(); println!("Unrolling last commit ({head_short})..."); git_status(&repo_root, &["reset", "--soft", "HEAD~1"])?; } if !opts.no_agent { run_fix_agent(&repo_root, &opts.agent, &message)?; } else { println!("Skipped fix agent (use without --no-agent to run Hive)."); } if stashed { println!("Restoring stashed changes..."); let _ = git_status(&repo_root, &["stash", "pop"]); } Ok(()) } fn resolve_fix_message(parts: &[String]) -> Result<String> { let joined = parts.join(" ").trim().to_string(); if joined.is_empty() { bail!("provide a fix message, e.g. `f fix last commit had spotify api leaked`"); } let Some(path) = detect_fix_input_file(parts) else { return Ok(joined); }; let content = fs::read_to_string(&path) .with_context(|| format!("failed to read fix input file {}", path.display()))?; let trimmed = content.trim(); if trimmed.is_empty() { bail!("fix input file is empty: {}", path.display()); } println!("Loaded fix context from {}", path.display()); Ok(format!( "Use this report as the source of truth for what to fix.\n\nReport file: {}\n\n{}", path.display(), trimmed )) } fn detect_fix_input_file(parts: &[String]) -> Option<PathBuf> { if parts.len() != 1 { return None; } let raw = parts[0].trim(); if raw.is_empty() { return None; } let candidate = raw.strip_prefix('@').unwrap_or(raw); let path = PathBuf::from(candidate); if !path.is_file() { return None; } Some(path.canonicalize().unwrap_or(path)) } fn try_run_commit_repair(repo_root: &std::path::Path, message: &str) -> Result<bool> { if !matches_recommit_request(message) { return Ok(false); } let status = git_output(repo_root, &["status", "--porcelain"])?; if !status.trim().is_empty() { let lines = vec![ "Working tree has uncommitted changes that will be included in the new commit." .to_string(), ]; if !confirm_with_tui("Re-commit", &lines, "Continue with re-commit? [Y/n]: ")? { bail!("Aborted."); } } let plan_lines = vec![ "Plan:".to_string(), " 1) git reset --soft HEAD~1 (undo last commit, keep changes staged)".to_string(), " 2) f commit (recreate commit with updated hygiene)".to_string(), ]; if !confirm_with_tui("Re-commit", &plan_lines, "Proceed? [Y/n]: ")? { bail!("Aborted."); } git_status(repo_root, &["reset", "--soft", "HEAD~1"])?; let status = Command::new("f") .arg("commit") .current_dir(repo_root) .status() .context("failed to run f commit")?; if !status.success() { bail!("f commit failed with status {}", status); } Ok(true) } fn confirm_with_tui(title: &str, lines: &[String], prompt: &str) -> Result<bool> { if let Some(answer) = opentui_prompt::confirm(title, lines, true) { return Ok(answer); } if !lines.is_empty() { for line in lines { println!("{}", line); } } confirm_default_yes(prompt) } fn matches_recommit_request(message: &str) -> bool { let lowered = message.to_ascii_lowercase(); let undo = lowered.contains("undo last commit") || lowered.contains("undo the last commit") || lowered.contains("reset last commit") || lowered.contains("reset the last commit") || lowered.contains("recommit"); let rerun = lowered.contains("run f commit") || lowered.contains("rerun f commit") || lowered.contains("run f commit again") || lowered.contains("re-run f commit") || lowered.contains("recommit and run f commit"); undo && rerun } fn confirm_default_yes(prompt: &str) -> Result<bool> { print!("{}", prompt); io::stdout().flush()?; if std::io::stdin().is_terminal() { let mut input = String::new(); io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(true); } return Ok(matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes")); } let mut input = String::new(); io::stdin().read_to_string(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(true); } Ok(matches!(trimmed.to_ascii_lowercase().as_str(), "y" | "yes")) } fn run_fix_agent(repo_root: &std::path::Path, agent: &str, message: &str) -> Result<()> { if which::which("hive").is_err() { bail!("hive not found in PATH. Install it or add it to PATH to run fix agent."); } let task = format!( "Fix this repo. Task: {message}\n\n\ If the issue involves leaked secrets, remove them from tracked files, \ update .gitignore if needed, and ensure the repo is safe to recommit." ); let status = Command::new("hive") .args(["agent", agent, &task]) .current_dir(repo_root) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run hive agent")?; if !status.success() { bail!("hive agent failed"); } Ok(()) } fn git_top_level() -> Result<std::path::PathBuf> { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output() .context("failed to run git")?; if !output.status.success() { bail!("not a git repository (or git not available)"); } let root = String::from_utf8_lossy(&output.stdout).trim().to_string(); if root.is_empty() { bail!("failed to resolve git repository root"); } Ok(std::path::PathBuf::from(root)) } fn ensure_clean_or_stash( repo_root: &std::path::Path, allow_stash: bool, stashed: &mut bool, ) -> Result<()> { let status = git_output(repo_root, &["status", "--porcelain"])?; if status.trim().is_empty() { return Ok(()); } if !allow_stash { bail!("working tree has uncommitted changes; commit/stash them or rerun with --stash"); } println!("Stashing local changes..."); git_status( repo_root, &["stash", "push", "-u", "-m", "f fix auto-stash"], )?; *stashed = true; Ok(()) } fn ensure_has_parent_commit(repo_root: &std::path::Path) -> Result<()> { let output = Command::new("git") .args(["rev-parse", "HEAD~1"]) .current_dir(repo_root) .output() .context("failed to check git history")?; if !output.status.success() { bail!("cannot unroll: repository has no parent commit"); } Ok(()) } fn git_output(repo_root: &std::path::Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .args(args) .current_dir(repo_root) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git {} failed: {}", args.join(" "), stderr.trim()); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn git_status(repo_root: &std::path::Path, args: &[&str]) -> Result<()> { let status = Command::new("git") .args(args) .current_dir(repo_root) .status() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } ================================================ FILE: src/fixup.rs ================================================ //! Fix common TOML syntax errors in flow.toml files. //! //! Common issues that AI tools create: //! - `\$` escape sequences (invalid in TOML basic strings) //! - `\n` literal in basic strings instead of actual newlines //! - Unclosed multi-line strings use std::fs; use anyhow::{Context, Result}; use regex::Regex; use crate::cli::FixupOpts; /// Result of a fixup operation. #[derive(Debug)] pub struct FixupResult { pub fixes_applied: Vec<FixupAction>, pub had_errors: bool, } #[derive(Debug)] pub struct FixupAction { pub line: usize, pub description: String, pub before: String, pub after: String, } pub fn run(opts: FixupOpts) -> Result<()> { let config_path = if opts.config.is_absolute() { opts.config.clone() } else { std::env::current_dir()?.join(&opts.config) }; if !config_path.exists() { anyhow::bail!("flow.toml not found at {}", config_path.display()); } let content = fs::read_to_string(&config_path) .with_context(|| format!("failed to read {}", config_path.display()))?; let result = fix_toml_content(&content); if result.fixes_applied.is_empty() { println!("✓ No issues found in {}", config_path.display()); return Ok(()); } println!( "Found {} issue(s) in {}:\n", result.fixes_applied.len(), config_path.display() ); for fix in &result.fixes_applied { println!(" Line {}: {}", fix.line, fix.description); println!(" - {}", truncate_for_display(&fix.before, 60)); println!(" + {}", truncate_for_display(&fix.after, 60)); println!(); } if opts.dry_run { println!("Dry run - no changes written."); return Ok(()); } // Apply fixes let fixed_content = apply_fixes(&content, &result.fixes_applied); // Validate the fixed content parses if let Err(e) = toml::from_str::<toml::Value>(&fixed_content) { println!("⚠ Warning: Fixed content still has TOML errors: {}", e); println!("Writing anyway - manual review recommended."); } fs::write(&config_path, &fixed_content) .with_context(|| format!("failed to write {}", config_path.display()))?; println!( "✓ Fixed {} issue(s) in {}", result.fixes_applied.len(), config_path.display() ); Ok(()) } /// Fix common TOML issues in the content. pub fn fix_toml_content(content: &str) -> FixupResult { let mut fixes = Vec::new(); let lines: Vec<&str> = content.lines().collect(); // Track if we're inside a multi-line basic string (""") let mut in_multiline_basic = false; let mut _multiline_start_line = 0; for (line_idx, line) in lines.iter().enumerate() { let line_num = line_idx + 1; // Count triple quotes to track multi-line string state let triple_quote_count = line.matches(r#"""""#).count(); if !in_multiline_basic { // Check for start of multi-line basic string if triple_quote_count == 1 { in_multiline_basic = true; _multiline_start_line = line_num; } else if triple_quote_count == 2 { // Single-line multi-line string (opens and closes on same line) // Check for issues in this line if let Some(fix) = check_invalid_escapes(line, line_num) { fixes.push(fix); } } } else { // Inside multi-line basic string if triple_quote_count >= 1 { // End of multi-line string in_multiline_basic = false; } // Check for invalid escape sequences inside multi-line basic strings if let Some(fix) = check_invalid_escapes(line, line_num) { fixes.push(fix); } } } FixupResult { fixes_applied: fixes, had_errors: false, } } /// Apply fixes to TOML content and return the updated string. pub fn apply_fixes_to_content(content: &str, fixes: &[FixupAction]) -> String { apply_fixes(content, fixes) } /// Check a line for invalid escape sequences in TOML basic strings. fn check_invalid_escapes(line: &str, line_num: usize) -> Option<FixupAction> { // Invalid escapes in TOML basic strings: \$ \: \@ \! etc. // Valid escapes: \\ \n \t \r \" \b \f \uXXXX \UXXXXXXXX and \ followed by newline // We need to find backslash followed by characters that are NOT valid escape chars let invalid_escape_re = Regex::new(r#"\\([^\\nrtbf"uU\s])"#).unwrap(); if let Some(capture) = invalid_escape_re.find(line) { let escaped_char = &line[capture.start() + 1..capture.end()]; let fixed_line = invalid_escape_re .replace_all(line, |caps: ®ex::Captures| { // Just remove the backslash, keep the character caps[1].to_string() }) .to_string(); return Some(FixupAction { line: line_num, description: format!("Invalid escape sequence '\\{}'", escaped_char), before: line.to_string(), after: fixed_line, }); } None } /// Apply fixes to content, returning the fixed string. fn apply_fixes(content: &str, fixes: &[FixupAction]) -> String { let lines: Vec<&str> = content.lines().collect(); let mut result_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect(); for fix in fixes { if fix.line > 0 && fix.line <= result_lines.len() { result_lines[fix.line - 1] = fix.after.clone(); } } // Preserve original line endings let has_trailing_newline = content.ends_with('\n'); let mut result = result_lines.join("\n"); if has_trailing_newline { result.push('\n'); } result } fn truncate_for_display(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { // Find valid UTF-8 char boundary let mut end = max_len.min(s.len()); while end > 0 && !s.is_char_boundary(end) { end -= 1; } format!("{}...", &s[..end]) } } #[cfg(test)] mod tests { use super::*; #[test] fn fixes_escaped_dollar() { let content = r##" [[tasks]] name = "test" command = """ echo "Price: \$8" """ "##; let result = fix_toml_content(content); assert_eq!(result.fixes_applied.len(), 1); assert!(result.fixes_applied[0].description.contains(r"\$")); } #[test] fn preserves_valid_escapes() { let content = r##" [[tasks]] name = "test" command = """ echo "Line1" echo "Tab here" """ "##; let result = fix_toml_content(content); assert!(result.fixes_applied.is_empty()); } #[test] fn no_fixes_needed() { let content = r#" [[tasks]] name = "test" command = "echo hello" "#; let result = fix_toml_content(content); assert!(result.fixes_applied.is_empty()); } } ================================================ FILE: src/flox.rs ================================================ use std::{ collections::BTreeMap, fs, path::{Path, PathBuf}, process::{Command, Stdio}, }; use anyhow::{Context, Result, bail}; use serde::Serialize; use crate::config::FloxInstallSpec; const MANIFEST_VERSION: u8 = 1; const ENV_VERSION: u8 = 1; /// Paths needed to invoke `flox activate` for a generated manifest. #[derive(Clone, Debug)] pub struct FloxEnv { pub project_root: PathBuf, pub manifest_path: PathBuf, pub lockfile_path: PathBuf, } #[derive(Serialize)] struct ManifestFile { version: u8, install: BTreeMap<String, FloxInstallSpec>, } #[derive(Serialize)] struct EnvJson { version: u8, manifest: String, lockfile: String, } /// Ensure a flox manifest exists for the given packages and return the paths to use. pub fn ensure_env(project_root: &Path, packages: &[(String, FloxInstallSpec)]) -> Result<FloxEnv> { ensure_env_at(project_root, packages) } pub fn ensure_env_at(root: &Path, packages: &[(String, FloxInstallSpec)]) -> Result<FloxEnv> { if packages.is_empty() { bail!("flox environment requested without any packages"); } let flox_bin = which::which("flox") .context("flox is required to use [deps]; install flox and ensure it is on PATH")?; let env_dir = root.join(".flox").join("env"); let manifest_path = env_dir.join("manifest.toml"); let lockfile_path = env_dir.join("manifest.lock"); fs::create_dir_all(&env_dir) .with_context(|| format!("failed to create flox env directory {}", env_dir.display()))?; let manifest_toml = render_manifest(packages)?; let manifest_changed = write_if_changed(&manifest_path, &manifest_toml)?; // Produce a lockfile so flox activations don't need to mutate state. if manifest_changed || !lockfile_path.exists() { let output = Command::new(&flox_bin) .arg("lock-manifest") .arg(&manifest_path) .output() .with_context(|| "failed to run 'flox lock-manifest'")?; if output.status.success() { write_if_changed( &lockfile_path, String::from_utf8_lossy(&output.stdout).as_ref(), )?; } else { let stderr = String::from_utf8_lossy(&output.stderr); bail!("flox lock-manifest failed: {}", stderr.trim()); } } write_env_json(root, &manifest_path, &lockfile_path)?; Ok(FloxEnv { project_root: root.to_path_buf(), manifest_path, lockfile_path, }) } /// Run a shell command inside the prepared flox environment. pub fn run_in_env(env: &FloxEnv, workdir: &Path, command: &str) -> Result<()> { write_env_json(&env.project_root, &env.manifest_path, &env.lockfile_path)?; let flox_bin = which::which("flox").context("flox is required to run tasks with flox deps")?; let status = Command::new(&flox_bin) .arg("activate") .arg("-d") .arg(&env.project_root) .arg("--") .arg("/bin/sh") .arg("-c") .arg(command) .current_dir(workdir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .status() .with_context(|| "failed to spawn flox activate for task")?; if status.success() { return Ok(()); } tracing::debug!( status = ?status.code(), "flox activate failed; running task with host PATH" ); run_on_host(workdir, command) } fn write_env_json(project_root: &Path, manifest_path: &Path, lockfile_path: &Path) -> Result<()> { let flox_root = project_root.join(".flox"); let top_level = flox_root.join("env.json"); let nested = flox_root.join("env").join("env.json"); let nested_json = EnvJson { version: ENV_VERSION, manifest: manifest_path.to_string_lossy().to_string(), lockfile: lockfile_path.to_string_lossy().to_string(), }; // top-level env.json with relative paths for flox CLI expectations let top_level_json = EnvJson { version: ENV_VERSION, manifest: "env/manifest.toml".to_string(), lockfile: "env/manifest.lock".to_string(), }; if let Some(parent) = top_level.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } if let Some(parent) = nested.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let top_level_contents = serde_json::to_string_pretty(&top_level_json) .context("failed to render top-level env.json")?; let nested_contents = serde_json::to_string_pretty(&nested_json).context("failed to render nested env.json")?; write_if_changed(&top_level, &top_level_contents)?; write_if_changed(&nested, &nested_contents)?; Ok(()) } fn run_on_host(workdir: &Path, command: &str) -> Result<()> { let host_status = Command::new("/bin/sh") .arg("-c") .arg(command) .current_dir(workdir) .status() .with_context(|| "failed to spawn command without managed env")?; if host_status.success() { Ok(()) } else { bail!( "command exited with status {}", host_status.code().unwrap_or(-1) ); } } fn render_manifest(packages: &[(String, FloxInstallSpec)]) -> Result<String> { let mut install = BTreeMap::new(); for (name, spec) in packages { install.insert(name.clone(), spec.clone()); } let manifest = ManifestFile { version: MANIFEST_VERSION, install, }; toml::to_string_pretty(&manifest).context("failed to render flox manifest") } fn write_if_changed(path: &Path, contents: &str) -> Result<bool> { let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents); if needs_write { fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?; } Ok(needs_write) } #[cfg(test)] mod tests { use super::*; #[test] fn manifest_renders_with_full_descriptor() { let deps = vec![( "ripgrep".to_string(), FloxInstallSpec { pkg_path: "ripgrep".into(), pkg_group: Some("tools".into()), version: Some("14".into()), systems: Some(vec!["x86_64-darwin".into()]), priority: Some(10), }, )]; let rendered = render_manifest(&deps).expect("render manifest"); assert!(rendered.contains("version = 1")); assert!(rendered.contains("[install.ripgrep]")); assert!(rendered.contains(r#"pkg-path = "ripgrep""#)); assert!(rendered.contains(r#"pkg-group = "tools""#)); assert!(rendered.contains(r#"version = "14""#)); assert!(rendered.contains(r#"priority = 10"#)); } } ================================================ FILE: src/gh_release.rs ================================================ //! GitHub release management. //! //! Provides functionality to: //! - Create GitHub releases with version tags //! - Upload release assets (binaries, tarballs) //! - List and manage existing releases use std::fs; use std::io::{self, Write}; use std::path::Path; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::cli::{GhReleaseAction, GhReleaseCommand, GhReleaseCreateOpts}; /// Run the release command. pub fn run(cmd: GhReleaseCommand) -> Result<()> { // Check if gh CLI is available if Command::new("gh").arg("--version").output().is_err() { bail!("GitHub CLI (gh) is not installed. Install from: https://cli.github.com"); } // Check if authenticated let auth_status = Command::new("gh") .args(["auth", "status"]) .output() .context("failed to check gh auth status")?; if !auth_status.status.success() { println!("Not authenticated with GitHub."); println!("Run: gh auth login"); bail!("GitHub authentication required"); } // Check if in a git repo if !Path::new(".git").exists() { bail!("Not in a git repository. Run this command from a git repo root."); } match cmd.action { Some(GhReleaseAction::Create(opts)) => create_release(opts), Some(GhReleaseAction::List { limit }) => list_releases(limit), Some(GhReleaseAction::Delete { tag, yes }) => delete_release(&tag, yes), Some(GhReleaseAction::Download { tag, output }) => { download_release(tag.as_deref(), &output) } None => list_releases(10), // Default action } } /// Create a new GitHub release. fn create_release(opts: GhReleaseCreateOpts) -> Result<()> { // Determine the tag let tag = match opts.tag { Some(t) => t, None => detect_version()?, }; // Ensure tag has 'v' prefix for consistency let tag = if tag.starts_with('v') { tag } else { format!("v{}", tag) }; println!("Creating release {}...", tag); // Check if tag already exists let tag_exists = Command::new("gh") .args(["release", "view", &tag]) .output() .map(|o| o.status.success()) .unwrap_or(false); if tag_exists { bail!( "Release {} already exists. Use a different version or delete the existing release.", tag ); } // Validate assets exist for asset in &opts.asset { if !Path::new(asset).exists() { bail!("Asset file not found: {}", asset); } } // Confirmation if !opts.yes { println!(); println!("Release details:"); println!(" Tag: {}", tag); if let Some(ref title) = opts.title { println!(" Title: {}", title); } if opts.draft { println!(" Type: Draft"); } if opts.prerelease { println!(" Type: Pre-release"); } if !opts.asset.is_empty() { println!(" Assets: {}", opts.asset.join(", ")); } println!(); print!("Create release? [Y/n]: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim().to_lowercase(); if input == "n" || input == "no" { println!("Aborted."); return Ok(()); } } // Build the gh release create command let mut args = vec!["release", "create", &tag]; let title_str; if let Some(ref title) = opts.title { args.push("--title"); title_str = title.clone(); args.push(&title_str); } let notes_str; if let Some(ref notes) = opts.notes { args.push("--notes"); notes_str = notes.clone(); args.push(¬es_str); } else if let Some(ref notes_file) = opts.notes_file { args.push("--notes-file"); args.push(notes_file); } else if opts.generate_notes { args.push("--generate-notes"); } if opts.draft { args.push("--draft"); } if opts.prerelease { args.push("--prerelease"); } let target_str; if let Some(ref target) = opts.target { args.push("--target"); target_str = target.clone(); args.push(&target_str); } // Add assets for asset in &opts.asset { args.push(asset); } println!("Running: gh {}", args.join(" ")); let status = Command::new("gh") .args(&args) .status() .context("failed to create release")?; if !status.success() { bail!("Failed to create release"); } println!(); println!("Release {} created successfully!", tag); // Show the release URL let url_output = Command::new("gh") .args(["release", "view", &tag, "--json", "url", "-q", ".url"]) .output(); if let Ok(output) = url_output { if output.status.success() { let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !url.is_empty() { println!("View at: {}", url); } } } Ok(()) } /// List recent releases. fn list_releases(limit: usize) -> Result<()> { let output = Command::new("gh") .args(["release", "list", "--limit", &limit.to_string()]) .output() .context("failed to list releases")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("no releases found") { println!("No releases found."); return Ok(()); } bail!("Failed to list releases: {}", stderr); } let stdout = String::from_utf8_lossy(&output.stdout); if stdout.trim().is_empty() { println!("No releases found."); } else { println!("{}", stdout); } Ok(()) } /// Delete a release. fn delete_release(tag: &str, yes: bool) -> Result<()> { // Check if release exists let exists = Command::new("gh") .args(["release", "view", tag]) .output() .map(|o| o.status.success()) .unwrap_or(false); if !exists { bail!("Release {} not found", tag); } if !yes { print!("Delete release {}? [y/N]: ", tag); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim().to_lowercase(); if input != "y" && input != "yes" { println!("Aborted."); return Ok(()); } } let status = Command::new("gh") .args(["release", "delete", tag, "--yes"]) .status() .context("failed to delete release")?; if !status.success() { bail!("Failed to delete release"); } println!("Release {} deleted.", tag); // Optionally delete the tag too print!("Also delete the git tag {}? [y/N]: ", tag); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim().to_lowercase(); if input == "y" || input == "yes" { Command::new("git").args(["tag", "-d", tag]).status().ok(); Command::new("git") .args(["push", "origin", &format!(":refs/tags/{}", tag)]) .status() .ok(); println!("Tag {} deleted.", tag); } Ok(()) } /// Download release assets. fn download_release(tag: Option<&str>, output: &str) -> Result<()> { let mut args = vec!["release", "download"]; if let Some(t) = tag { args.push(t); } args.push("--dir"); args.push(output); // Create output directory if needed if output != "." { fs::create_dir_all(output).context("failed to create output directory")?; } println!("Downloading release assets to {}...", output); let status = Command::new("gh") .args(&args) .status() .context("failed to download release")?; if !status.success() { bail!("Failed to download release assets"); } println!("Download complete."); Ok(()) } /// Detect version from Cargo.toml or package.json. fn detect_version() -> Result<String> { // Try Cargo.toml first if Path::new("Cargo.toml").exists() { let content = fs::read_to_string("Cargo.toml")?; for line in content.lines() { if line.starts_with("version") { if let Some(version) = line.split('=').nth(1) { let version = version.trim().trim_matches('"').trim_matches('\''); return Ok(version.to_string()); } } } } // Try package.json if Path::new("package.json").exists() { let content = fs::read_to_string("package.json")?; if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) { if let Some(version) = json.get("version").and_then(|v| v.as_str()) { return Ok(version.to_string()); } } } // Try pyproject.toml if Path::new("pyproject.toml").exists() { let content = fs::read_to_string("pyproject.toml")?; for line in content.lines() { if line.starts_with("version") { if let Some(version) = line.split('=').nth(1) { let version = version.trim().trim_matches('"').trim_matches('\''); return Ok(version.to_string()); } } } } // Try to get from git tags let output = Command::new("git") .args(["describe", "--tags", "--abbrev=0"]) .output(); if let Ok(output) = output { if output.status.success() { let tag = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !tag.is_empty() { // Increment patch version let version = tag.strip_prefix('v').unwrap_or(&tag); let parts: Vec<&str> = version.split('.').collect(); if parts.len() >= 3 { if let Ok(patch) = parts[2].parse::<u32>() { return Ok(format!("{}.{}.{}", parts[0], parts[1], patch + 1)); } } return Ok(version.to_string()); } } } bail!( "Could not detect version. Please specify with: f release create <tag>\n\ Or add version to Cargo.toml, package.json, or pyproject.toml" ) } ================================================ FILE: src/git_guard.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::cli::GitRepairOpts; /// Returns true when the repo has a `.jj` directory (jj colocated mode). fn is_jj_colocated(repo_root: &Path) -> bool { repo_root.join(".jj").is_dir() } fn git_capture_in(repo_root: &Path, args: &[&str]) -> Option<String> { let out = Command::new("git") .current_dir(repo_root) .args(args) .output() .ok()?; if !out.status.success() { return None; } Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) } fn has_working_tree_changes(repo_root: &Path) -> bool { match git_capture_in(repo_root, &["status", "--porcelain"]) { Some(s) => !s.trim().is_empty(), None => false, } } fn short_sha(sha: &str) -> &str { if sha.len() <= 7 { sha } else { &sha[..7] } } fn attach_detached_head_to_keep_branch(repo_root: &Path) -> Result<bool> { if !is_jj_colocated(repo_root) { return Ok(false); } let Some(head) = git_capture_in(repo_root, &["rev-parse", "HEAD"]) else { return Ok(false); }; if head.trim().is_empty() { return Ok(false); } let branch = format!("jj/keep/{}", head.trim()); // If it already exists, just check it out. if git_ref_exists(repo_root, &branch) { let status = Command::new("git") .current_dir(repo_root) .args(["checkout", &branch]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); return Ok(matches!(status, Ok(s) if s.success())); } // Create and check out (at HEAD) without touching the working tree. let status = Command::new("git") .current_dir(repo_root) .args(["checkout", "-b", &branch, head.trim()]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); Ok(matches!(status, Ok(s) if s.success())) } /// In a jj-colocated repo, detached HEAD is common. For git-based workflows, /// we need to ensure HEAD is attached to a local branch. /// /// Strategy: /// - If main/master exists AND points at the current HEAD commit, attach to it /// (no working tree changes). /// - Otherwise, attach HEAD to a synthetic `jj/keep/<sha>` branch at the current commit. fn jj_auto_checkout(repo_root: &Path) -> Result<bool> { if !is_jj_colocated(repo_root) { return Ok(false); } let Some(head) = git_capture_in(repo_root, &["rev-parse", "HEAD"]) else { return Ok(false); }; for target in ["main", "master"] { if !git_ref_exists(repo_root, target) { continue; } let Some(target_sha) = git_capture_in(repo_root, &["rev-parse", target]) else { continue; }; if target_sha == head { let status = Command::new("git") .current_dir(repo_root) .args(["checkout", target]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); if matches!(status, Ok(s) if s.success()) { return Ok(true); } } } attach_detached_head_to_keep_branch(repo_root) } fn resolve_land_target_branch(repo_root: &Path, requested: &str) -> Result<String> { if git_ref_exists(repo_root, requested) { return Ok(requested.to_string()); } if requested == "main" && git_ref_exists(repo_root, "master") { return Ok("master".to_string()); } bail!( "Target branch '{}' not found (and fallback branch unavailable).", requested ); } fn ensure_clean_working_tree_for_land(repo_root: &Path) -> Result<()> { let status = git_capture_in(repo_root, &["status", "--porcelain"]).unwrap_or_default(); if !status.trim().is_empty() { bail!("Cannot land onto main with uncommitted changes. Commit or stash first."); } Ok(()) } fn land_head_to_branch(repo_root: &Path, requested_target: &str) -> Result<()> { ensure_clean_working_tree_for_land(repo_root)?; let current = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|| "HEAD".to_string()); if current.trim() == "HEAD" { bail!("HEAD is detached. Run `f git-repair` first."); } let current = current.trim().to_string(); let target = resolve_land_target_branch(repo_root, requested_target)?; if current == target { println!("Already on {}", target); return Ok(()); } let head_sha = git_capture_in(repo_root, &["rev-parse", "HEAD"]) .ok_or_else(|| anyhow::anyhow!("failed to resolve HEAD commit"))?; git_run_in(repo_root, &["checkout", &target])?; match git_run_in(repo_root, &["cherry-pick", head_sha.trim()]) { Ok(_) => { println!( "✓ Landed commit {} from {} onto {}", short_sha(head_sha.trim()), current, target ); Ok(()) } Err(err) => { let conflicts = git_unmerged_files(repo_root); let _ = git_run_in(repo_root, &["cherry-pick", "--abort"]); let _ = git_run_in(repo_root, &["checkout", ¤t]); eprintln!( "Landing failed: commit {} conflicts with {}. Flow aborted cherry-pick and returned to {}.", short_sha(head_sha.trim()), target, current ); if !conflicts.is_empty() { eprintln!("Conflicting files:"); for file in conflicts { eprintln!(" - {}", file); } } eprintln!( "No data was lost. You can rebase {} onto {} and retry.", current, target ); Err(err).context("failed to land commit onto target branch") } } } #[derive(Debug, Clone)] struct GitState { rebase: bool, merge: bool, cherry_pick: bool, revert: bool, bisect: bool, detached: bool, unmerged_files: Vec<String>, } pub fn ensure_clean_for_commit(repo_root: &Path) -> Result<()> { ensure_clean_state(repo_root, "commit") } pub fn ensure_clean_for_push(repo_root: &Path) -> Result<()> { ensure_clean_state(repo_root, "push") } fn ensure_clean_state(repo_root: &Path, action: &str) -> Result<()> { let state = detect_git_state(repo_root)?; let mut issues = Vec::new(); if state.rebase { issues.push("rebase in progress".to_string()); } if state.merge { issues.push("merge in progress".to_string()); } if state.cherry_pick { issues.push("cherry-pick in progress".to_string()); } if state.revert { issues.push("revert in progress".to_string()); } if state.bisect { issues.push("bisect in progress".to_string()); } if !state.unmerged_files.is_empty() { issues.push(format!( "unmerged files: {}", state.unmerged_files.join(", ") )); } if state.detached { // In jj-colocated repos, detached HEAD is normal — auto-fix it. if !jj_auto_checkout(repo_root)? { issues.push("detached HEAD".to_string()); } } if !issues.is_empty() { let mut msg = format!("Git state not clean for {}:", action); for issue in issues { msg.push_str(&format!("\n - {}", issue)); } msg.push_str("\n\nRun `f git-repair` or resolve manually before continuing."); bail!(msg); } Ok(()) } pub fn run_git_repair(opts: GitRepairOpts) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; let repo_root = find_repo_root(&cwd)?; let branch = opts.branch.as_deref().unwrap_or("main"); let state = detect_git_state(&repo_root)?; if opts.dry_run { print_state(&state, branch, opts.land_main); return Ok(()); } let mut did_work = false; if state.rebase { let _ = git_run_in(&repo_root, &["rebase", "--abort"]); did_work = true; } if state.merge { let _ = git_run_in(&repo_root, &["merge", "--abort"]); did_work = true; } if state.cherry_pick { let _ = git_run_in(&repo_root, &["cherry-pick", "--abort"]); did_work = true; } if state.revert { let _ = git_run_in(&repo_root, &["revert", "--abort"]); did_work = true; } if state.bisect { let _ = git_run_in(&repo_root, &["bisect", "reset"]); did_work = true; } if state.detached { // In jj-colocated repos, prefer attaching HEAD to a safe local branch. // If there are working copy changes, do NOT try to checkout main/master (it can overwrite). if is_jj_colocated(&repo_root) && has_working_tree_changes(&repo_root) { if attach_detached_head_to_keep_branch(&repo_root)? { did_work = true; } else { bail!( "Detached HEAD in jj-colocated repo with local changes, but failed to attach to a keep branch." ); } } else if jj_auto_checkout(&repo_root)? { did_work = true; } else { let target = if git_ref_exists(&repo_root, branch) { branch.to_string() } else if git_ref_exists(&repo_root, "master") { "master".to_string() } else { bail!( "Detached HEAD and branch '{}' not found. Checkout a branch manually.", branch ); }; git_run_in(&repo_root, &["checkout", &target])?; did_work = true; } } if opts.land_main { land_head_to_branch(&repo_root, branch)?; did_work = true; } if did_work { println!("✓ Git repair complete"); } else { println!("No repair needed"); } Ok(()) } fn detect_git_state(repo_root: &Path) -> Result<GitState> { let git_dir = git_dir(repo_root)?; let rebase = git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists(); let merge = git_dir.join("MERGE_HEAD").exists(); let cherry_pick = git_dir.join("CHERRY_PICK_HEAD").exists(); let revert = git_dir.join("REVERT_HEAD").exists(); let bisect = git_dir.join("BISECT_LOG").exists(); let detached = is_detached_head(repo_root)?; let unmerged_files = git_unmerged_files(repo_root); Ok(GitState { rebase, merge, cherry_pick, revert, bisect, detached, unmerged_files, }) } fn git_dir(repo_root: &Path) -> Result<PathBuf> { let output = Command::new("git") .current_dir(repo_root) .args(["rev-parse", "--git-dir"]) .output() .context("failed to locate git directory")?; if !output.status.success() { bail!("Not a git repository"); } let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); let dir = PathBuf::from(raw); if dir.is_absolute() { Ok(dir) } else { Ok(repo_root.join(dir)) } } fn is_detached_head(repo_root: &Path) -> Result<bool> { let output = Command::new("git") .current_dir(repo_root) .args(["symbolic-ref", "--quiet", "--short", "HEAD"]) .output() .context("failed to check HEAD")?; Ok(!output.status.success()) } fn git_unmerged_files(repo_root: &Path) -> Vec<String> { let output = Command::new("git") .current_dir(repo_root) .args(["diff", "--name-only", "--diff-filter=U"]) .output(); match output { Ok(out) => String::from_utf8_lossy(&out.stdout) .lines() .filter(|l| !l.trim().is_empty()) .map(|l| l.trim().to_string()) .collect(), Err(_) => Vec::new(), } } fn git_run_in(repo_root: &Path, args: &[&str]) -> Result<()> { let status = Command::new("git") .current_dir(repo_root) .args(args) .status() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } fn git_ref_exists(repo_root: &Path, name: &str) -> bool { Command::new("git") .current_dir(repo_root) .args(["show-ref", "--verify", &format!("refs/heads/{}", name)]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } fn print_state(state: &GitState, branch: &str, land_main: bool) { println!("Git repair dry-run:"); println!(" rebase: {}", state.rebase); println!(" merge: {}", state.merge); println!(" cherry-pick: {}", state.cherry_pick); println!(" revert: {}", state.revert); println!(" bisect: {}", state.bisect); println!(" detached: {}", state.detached); if !state.unmerged_files.is_empty() { println!(" unmerged files: {}", state.unmerged_files.join(", ")); } println!(" target branch: {}", branch); println!(" land main: {}", land_main); } fn find_repo_root(start: &Path) -> Result<PathBuf> { let output = Command::new("git") .current_dir(start) .args(["rev-parse", "--show-toplevel"]) .output() .context("failed to find git repository")?; if !output.status.success() { bail!("Not in a git repository"); } let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); Ok(PathBuf::from(path)) } ================================================ FILE: src/gitignore_policy.rs ================================================ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::env; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use serde::Deserialize; use crate::cli::{GitignoreAction, GitignoreCommand, GitignorePolicyInitOpts, GitignoreScanOpts}; use crate::config; const POLICY_OVERRIDE_ENV: &str = "FLOW_ALLOW_GITIGNORE_POLICY"; const POLICY_FILE_NAME: &str = "gitignore-policy.toml"; const DEFAULT_REPOS_ROOT: &str = "~/repos"; const DEFAULT_BLOCKED_PATTERNS: &[&str] = &[".ai/todos/*.bike", ".beads/", ".rise/"]; const DEFAULT_ALLOWED_OWNERS: &[&str] = &["nikivdev"]; #[derive(Debug, Clone)] pub struct GitignorePolicy { pub blocked_patterns: Vec<String>, pub allowed_owners: Vec<String>, pub repos_roots: Vec<PathBuf>, } #[derive(Debug, Deserialize, Default)] struct GitignorePolicyFile { blocked_patterns: Option<Vec<String>>, allowed_owners: Option<Vec<String>>, repos_roots: Option<Vec<String>>, } #[derive(Debug, Clone)] struct Violation { file: PathBuf, line: usize, entry: String, blocked_pattern: String, } impl Default for GitignorePolicy { fn default() -> Self { Self { blocked_patterns: DEFAULT_BLOCKED_PATTERNS .iter() .map(|s| s.to_string()) .collect(), allowed_owners: DEFAULT_ALLOWED_OWNERS .iter() .map(|s| s.to_ascii_lowercase()) .collect(), repos_roots: vec![expand_home(DEFAULT_REPOS_ROOT)], } } } pub fn run(cmd: GitignoreCommand) -> Result<()> { match cmd .action .unwrap_or(GitignoreAction::Audit(GitignoreScanOpts { root: None, all: false, })) { GitignoreAction::Audit(opts) => run_scan(opts, false), GitignoreAction::Fix(opts) => run_scan(opts, true), GitignoreAction::PolicyInit(opts) => init_policy_file(opts), GitignoreAction::SetupGlobal { print_only } => setup_global_gitignore(print_only), GitignoreAction::PolicyPath => { println!("{}", policy_path().display()); Ok(()) } } } pub fn enforce_staged_policy(repo_root: &Path) -> Result<()> { if policy_override_enabled() { return Ok(()); } let policy = load_policy(); if !is_external_repo(repo_root, &policy) { return Ok(()); } let violations = staged_gitignore_violations(repo_root, &policy)?; if violations.is_empty() { return Ok(()); } eprintln!("Refusing to commit personal tooling ignore entries in an external repo:"); for v in &violations { eprintln!( " - {}:{} adds '{}' (blocked by policy '{}')", v.file.display(), v.line, v.entry, v.blocked_pattern ); } eprintln!(); eprintln!("Use global gitignore for personal tooling, then retry."); eprintln!("To clean existing repos: f gitignore fix"); eprintln!("Override once with {}=1", POLICY_OVERRIDE_ENV); bail!("blocked personal tooling .gitignore entries") } pub fn load_policy() -> GitignorePolicy { let mut policy = GitignorePolicy::default(); let path = policy_path(); if !path.exists() { return policy; } let content = match fs::read_to_string(&path) { Ok(content) => content, Err(err) => { eprintln!( "warn: failed to read {}: {} (using defaults)", path.display(), err ); return policy; } }; let parsed: GitignorePolicyFile = match toml::from_str(&content) { Ok(parsed) => parsed, Err(err) => { eprintln!( "warn: failed to parse {}: {} (using defaults)", path.display(), err ); return policy; } }; if let Some(patterns) = parsed.blocked_patterns { let cleaned = clean_patterns(patterns.into_iter()); if !cleaned.is_empty() { policy.blocked_patterns = cleaned; } } if let Some(owners) = parsed.allowed_owners { let cleaned: Vec<String> = owners .into_iter() .map(|s| s.trim().to_ascii_lowercase()) .filter(|s| !s.is_empty()) .collect(); if !cleaned.is_empty() { policy.allowed_owners = cleaned; } } if let Some(roots) = parsed.repos_roots { let cleaned: Vec<PathBuf> = roots .into_iter() .map(|s| expand_home(s.trim())) .filter(|p| !p.as_os_str().is_empty()) .collect(); if !cleaned.is_empty() { policy.repos_roots = cleaned; } } policy } pub fn policy_path() -> PathBuf { config::global_config_dir().join(POLICY_FILE_NAME) } pub fn is_external_repo(repo_root: &Path, policy: &GitignorePolicy) -> bool { let Some(owner) = repo_origin_owner(repo_root) else { return true; }; !policy .allowed_owners .iter() .any(|o| o.eq_ignore_ascii_case(owner.as_str())) } fn run_scan(opts: GitignoreScanOpts, apply_fix: bool) -> Result<()> { let policy = load_policy(); let roots = scan_roots(&opts, &policy); let mut repo_roots: Vec<PathBuf> = Vec::new(); for root in roots { repo_roots.extend(discover_repo_roots(&root)); } repo_roots.sort(); repo_roots.dedup(); if repo_roots.is_empty() { println!("No repositories found."); return Ok(()); } let mut findings_by_repo: BTreeMap<PathBuf, Vec<Violation>> = BTreeMap::new(); let mut touched_files: BTreeSet<PathBuf> = BTreeSet::new(); for repo_root in repo_roots { if !opts.all && !is_external_repo(&repo_root, &policy) { continue; } if apply_fix { let changed = fix_repo_gitignores(&repo_root, &policy)?; touched_files.extend(changed); } let repo_findings = inspect_repo_gitignores(&repo_root, &policy)?; if !repo_findings.is_empty() { findings_by_repo.insert(repo_root, repo_findings); } } if apply_fix { if touched_files.is_empty() { println!("No policy entries needed removal."); } else { println!( "Removed policy entries from {} .gitignore file(s).", touched_files.len() ); for path in touched_files { println!(" - {}", path.display()); } } } if findings_by_repo.is_empty() { println!("No blocked personal-tooling patterns found."); return Ok(()); } println!("Blocked personal-tooling patterns found:"); for (repo, findings) in &findings_by_repo { println!("\n{}", repo.display()); for v in findings { println!( " - {}:{} '{}' (blocked by '{}')", v.file.display(), v.line, v.entry, v.blocked_pattern ); } } if apply_fix { bail!("Some blocked entries remain; review output above") } else { bail!("Found blocked personal-tooling entries") } } fn init_policy_file(opts: GitignorePolicyInitOpts) -> Result<()> { let path = policy_path(); let parent = path .parent() .ok_or_else(|| anyhow::anyhow!("invalid policy path {}", path.display()))?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; if path.exists() && !opts.force { bail!( "{} already exists (use --force to overwrite)", path.display() ); } fs::write(&path, default_policy_template()) .with_context(|| format!("failed to write {}", path.display()))?; println!("Wrote {}", path.display()); Ok(()) } fn setup_global_gitignore(print_only: bool) -> Result<()> { let policy = load_policy(); let target = resolve_global_excludes_path()?; if print_only { println!("Global excludes file: {}", target.display()); println!("Patterns:"); for pattern in &policy.blocked_patterns { println!(" - {}", pattern); } return Ok(()); } if let Some(parent) = target.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let existing = fs::read_to_string(&target).unwrap_or_default(); let mut lines: Vec<String> = existing.lines().map(|s| s.to_string()).collect(); let mut appended = 0usize; for pattern in &policy.blocked_patterns { let wanted = pattern.trim(); let Some(target_norm) = normalize_entry(wanted) else { continue; }; let present = lines.iter().any(|line| { normalize_entry(line) .map(|norm| norm == target_norm) .unwrap_or(false) }); if present { continue; } lines.push(wanted.to_string()); appended += 1; } let mut rendered = lines.join("\n"); if !rendered.is_empty() { rendered.push('\n'); } fs::write(&target, rendered) .with_context(|| format!("failed to write {}", target.display()))?; ensure_global_excludes_config(&target)?; if appended == 0 { println!("Global excludes already up to date: {}", target.display()); } else { println!( "Added {} pattern(s) to global excludes: {}", appended, target.display() ); } Ok(()) } fn resolve_global_excludes_path() -> Result<PathBuf> { if let Some(configured) = git_capture_global_config("core.excludesFile")? { return Ok(expand_home(configured.trim())); } Ok(home_dir_or_default().join(".config/git/ignore")) } fn ensure_global_excludes_config(path: &Path) -> Result<()> { let current = git_capture_global_config("core.excludesFile")?; if let Some(current) = current { let current_path = expand_home(current.trim()); if current_path == path { return Ok(()); } } let value = path.to_string_lossy().to_string(); let status = Command::new("git") .args(["config", "--global", "core.excludesFile", &value]) .status() .context("failed to run git config --global core.excludesFile")?; if !status.success() { bail!("git config --global core.excludesFile failed") } Ok(()) } fn git_capture_global_config(key: &str) -> Result<Option<String>> { let output = Command::new("git") .args(["config", "--global", "--get", key]) .output() .with_context(|| format!("failed to read global git config key {}", key))?; if !output.status.success() { return Ok(None); } let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); if value.is_empty() { Ok(None) } else { Ok(Some(value)) } } fn staged_gitignore_violations( repo_root: &Path, policy: &GitignorePolicy, ) -> Result<Vec<Violation>> { let staged_files = staged_gitignore_files(repo_root)?; if staged_files.is_empty() { return Ok(Vec::new()); } let blocked = blocked_lookup(policy); let mut violations = Vec::new(); for file in staged_files { let output = Command::new("git") .current_dir(repo_root) .args(["diff", "--cached", "-U0", "--", &file]) .output() .with_context(|| format!("failed to inspect staged diff for {}", file))?; if !output.status.success() { continue; } let diff = String::from_utf8_lossy(&output.stdout); let mut line_no: usize = 0; for line in diff.lines() { if line.starts_with("@@") { line_no = parse_hunk_new_line(line).unwrap_or(0); continue; } if let Some(rest) = line.strip_prefix('+') { if line.starts_with("+++") { continue; } if let Some(normalized) = normalize_entry(rest) { if let Some((_, blocked_pattern)) = blocked.iter().find(|(norm, _)| norm == &normalized) { violations.push(Violation { file: PathBuf::from(&file), line: if line_no == 0 { 1 } else { line_no }, entry: rest.trim().to_string(), blocked_pattern: blocked_pattern.clone(), }); } } line_no = line_no.saturating_add(1); continue; } if line.starts_with(' ') { line_no = line_no.saturating_add(1); } } } Ok(violations) } fn staged_gitignore_files(repo_root: &Path) -> Result<Vec<String>> { let output = Command::new("git") .current_dir(repo_root) .args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]) .output() .context("failed to list staged files")?; if !output.status.success() { bail!("git diff --cached --name-only failed") } Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(str::trim) .filter(|s| s.ends_with(".gitignore")) .map(|s| s.to_string()) .collect()) } fn inspect_repo_gitignores(repo_root: &Path, policy: &GitignorePolicy) -> Result<Vec<Violation>> { let blocked = blocked_lookup(policy); let files = list_gitignore_files(repo_root); let mut out = Vec::new(); for file in files { let content = fs::read_to_string(&file) .with_context(|| format!("failed to read {}", file.display()))?; for (idx, line) in content.lines().enumerate() { let Some(normalized) = normalize_entry(line) else { continue; }; if let Some((_, blocked_pattern)) = blocked.iter().find(|(norm, _)| norm == &normalized) { out.push(Violation { file: file.clone(), line: idx + 1, entry: line.trim().to_string(), blocked_pattern: blocked_pattern.clone(), }); } } } Ok(out) } fn fix_repo_gitignores(repo_root: &Path, policy: &GitignorePolicy) -> Result<Vec<PathBuf>> { let blocked: HashSet<String> = blocked_lookup(policy).into_iter().map(|(n, _)| n).collect(); let files = list_gitignore_files(repo_root); let mut changed = Vec::new(); for file in files { let content = fs::read_to_string(&file) .with_context(|| format!("failed to read {}", file.display()))?; let had_trailing_newline = content.ends_with('\n'); let mut kept = Vec::new(); let mut removed_any = false; for line in content.lines() { let remove = normalize_entry(line) .map(|normalized| blocked.contains(&normalized)) .unwrap_or(false); if remove { removed_any = true; continue; } kept.push(line.to_string()); } if !removed_any { continue; } let mut new_content = kept.join("\n"); if had_trailing_newline && !new_content.is_empty() { new_content.push('\n'); } fs::write(&file, new_content) .with_context(|| format!("failed to write {}", file.display()))?; changed.push(file); } Ok(changed) } fn list_gitignore_files(repo_root: &Path) -> Vec<PathBuf> { let mut files = Vec::new(); collect_gitignore_files(repo_root, 0, 64, &mut files); files.sort(); files } fn discover_repo_roots(root: &Path) -> Vec<PathBuf> { let mut repos: BTreeSet<PathBuf> = BTreeSet::new(); collect_repo_roots(root, 0, 4, &mut repos); repos.into_iter().collect() } fn collect_gitignore_files(dir: &Path, depth: usize, max_depth: usize, out: &mut Vec<PathBuf>) { if depth > max_depth { return; } let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let Ok(ft) = entry.file_type() else { continue; }; if ft.is_file() { if path.file_name() == Some(OsStr::new(".gitignore")) { out.push(path); } continue; } if !ft.is_dir() { continue; } let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); if name == ".git" || name == "node_modules" || name == "target" { continue; } collect_gitignore_files(&path, depth + 1, max_depth, out); } } fn collect_repo_roots(dir: &Path, depth: usize, max_depth: usize, out: &mut BTreeSet<PathBuf>) { if depth > max_depth { return; } let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let Ok(ft) = entry.file_type() else { continue; }; if !ft.is_dir() { continue; } let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); if name == ".git" { if let Some(parent) = path.parent() { out.insert(parent.to_path_buf()); } continue; } if name == "node_modules" || name == "target" { continue; } collect_repo_roots(&path, depth + 1, max_depth, out); } } fn scan_roots(opts: &GitignoreScanOpts, policy: &GitignorePolicy) -> Vec<PathBuf> { if let Some(root) = opts.root.as_deref() { return vec![expand_home(root)]; } if !policy.repos_roots.is_empty() { return policy.repos_roots.clone(); } vec![expand_home(DEFAULT_REPOS_ROOT)] } fn blocked_lookup(policy: &GitignorePolicy) -> Vec<(String, String)> { policy .blocked_patterns .iter() .filter_map(|p| normalize_entry(p).map(|norm| (norm, p.trim().to_string()))) .collect() } fn clean_patterns<I>(patterns: I) -> Vec<String> where I: Iterator<Item = String>, { patterns .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } fn normalize_entry(line: &str) -> Option<String> { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { return None; } let content = if let Some((head, _)) = trimmed.split_once(" #") { head.trim() } else { trimmed }; let normalized = content.trim_start_matches('/').trim(); if normalized.is_empty() { return None; } Some(normalized.to_string()) } fn parse_hunk_new_line(hunk: &str) -> Option<usize> { let plus = hunk.find('+')?; let after_plus = &hunk[plus + 1..]; let digits: String = after_plus .chars() .take_while(|c| c.is_ascii_digit()) .collect(); digits.parse::<usize>().ok() } fn repo_origin_owner(repo_root: &Path) -> Option<String> { let output = Command::new("git") .current_dir(repo_root) .args(["remote", "get-url", "origin"]) .output() .ok()?; if !output.status.success() { return None; } let url = String::from_utf8_lossy(&output.stdout); parse_github_owner(url.trim()) } fn parse_github_owner(url: &str) -> Option<String> { let trimmed = url.trim().trim_end_matches('/'); if let Some(rest) = trimmed.strip_prefix("git@github.com:") { let repo = rest.trim_end_matches(".git"); return repo.split('/').next().map(|s| s.to_string()); } if let Some(rest) = trimmed.strip_prefix("https://github.com/") { let repo = rest.trim_end_matches(".git"); return repo.split('/').next().map(|s| s.to_string()); } None } fn policy_override_enabled() -> bool { env::var(POLICY_OVERRIDE_ENV) .ok() .map(|v| { let v = v.trim().to_ascii_lowercase(); v == "1" || v == "true" || v == "yes" }) .unwrap_or(false) } fn expand_home(input: &str) -> PathBuf { let trimmed = input.trim(); if trimmed == "~" { return home_dir_or_default(); } if let Some(rest) = trimmed.strip_prefix("~/") { return home_dir_or_default().join(rest); } PathBuf::from(trimmed) } fn home_dir_or_default() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) } fn default_policy_template() -> String { format!( "# Flow gitignore policy\n#\n# These patterns are local developer tooling noise and should stay out of\n# external/public repositories.\n\nblocked_patterns = [\n \"{}\",\n \"{}\",\n \"{}\",\n]\n\n# Repositories owned by these GitHub users are treated as internal and exempt.\nallowed_owners = [\n \"{}\",\n]\n\n# Roots scanned by `f gitignore audit` and `f gitignore fix` when --root is omitted.\nrepos_roots = [\n \"{}\",\n]\n", DEFAULT_BLOCKED_PATTERNS[0], DEFAULT_BLOCKED_PATTERNS[1], DEFAULT_BLOCKED_PATTERNS[2], DEFAULT_ALLOWED_OWNERS[0], DEFAULT_REPOS_ROOT, ) } #[cfg(test)] mod tests { use super::*; #[test] fn normalize_entry_ignores_comments_and_slashes() { assert_eq!(normalize_entry("/.beads/"), Some(".beads/".to_string())); assert_eq!( normalize_entry(".rise/ # local"), Some(".rise/".to_string()) ); assert_eq!(normalize_entry("# note"), None); } #[test] fn parse_github_owner_from_remote_url() { assert_eq!( parse_github_owner("https://github.com/pqrs-org/Karabiner-Elements.git"), Some("pqrs-org".to_string()) ); assert_eq!( parse_github_owner("git@github.com:nikivdev/Karabiner-Elements.git"), Some("nikivdev".to_string()) ); } } ================================================ FILE: src/hash.rs ================================================ use std::env; use std::io::IsTerminal; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::cli::HashOpts; use crate::env as flow_env; const LINK_PREFIX: &str = "unstash./"; pub fn run(opts: HashOpts) -> Result<()> { if opts.args.is_empty() { bail!("Usage: f hash <paths or unhash args>"); } let unhash_bin = which::which("unhash") .context("unhash not found on PATH. Run `f deploy-unhash` in the unhash repo.")?; let mut cmd = Command::new(unhash_bin); cmd.args(&opts.args); if env::var("UNHASH_KEY").is_err() { if let Ok(Some(value)) = flow_env::get_personal_env_var("UNHASH_KEY") { cmd.env("UNHASH_KEY", value); } } let output = cmd.output().context("failed to run unhash")?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); bail!("unhash failed: {}\n{}{}", output.status, stdout, stderr); } let stdout = String::from_utf8_lossy(&output.stdout); let mut lines = stdout.lines().filter(|line| !line.trim().is_empty()); let hash = lines .next() .ok_or_else(|| anyhow::anyhow!("unhash output missing hash"))? .trim() .to_string(); let link = format!("{LINK_PREFIX}{hash}"); copy_to_clipboard(&link)?; println!("{hash}"); println!("{link}"); if let Some(path_line) = lines.next() { println!("{}", path_line.trim()); } Ok(()) } fn copy_to_clipboard(text: &str) -> Result<()> { if std::env::var("FLOW_NO_CLIPBOARD").is_ok() || !std::io::stdin().is_terminal() { return Ok(()); } #[cfg(target_os = "macos")] { let mut child = Command::new("pbcopy") .stdin(std::process::Stdio::piped()) .spawn() .context("failed to spawn pbcopy")?; if let Some(stdin) = child.stdin.as_mut() { use std::io::Write; stdin.write_all(text.as_bytes())?; } child.wait()?; } #[cfg(target_os = "linux")] { let result = Command::new("xclip") .arg("-selection") .arg("clipboard") .stdin(std::process::Stdio::piped()) .spawn(); let mut child = match result { Ok(c) => c, Err(_) => Command::new("xsel") .arg("--clipboard") .arg("--input") .stdin(std::process::Stdio::piped()) .spawn() .context("failed to spawn xclip or xsel")?, }; if let Some(stdin) = child.stdin.as_mut() { use std::io::Write; stdin.write_all(text.as_bytes())?; } child.wait()?; } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { bail!("clipboard not supported on this platform"); } Ok(()) } ================================================ FILE: src/health.rs ================================================ use std::collections::HashMap; use std::env; use std::fs; use std::path::PathBuf; use std::process::Command; use std::time::Duration; use anyhow::{Context, Result, bail}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STD}; use crate::cli::HealthOpts; use crate::config; use crate::doctor; use crate::env as flow_env; use crate::http_client; use crate::setup::add_gitignore_entry; pub fn run(_opts: HealthOpts) -> Result<()> { println!("Running flow health checks...\n"); ensure_run_layout()?; ensure_fish_shell()?; ensure_fish_flow_init()?; ensure_gitignore()?; doctor::run(crate::cli::DoctorOpts {})?; ensure_ai_server()?; ensure_unhash()?; ensure_rise_health()?; ensure_linsa_base_health()?; ensure_zerg_ai_health()?; println!("\n✅ flow health checks passed."); Ok(()) } fn ensure_run_layout() -> Result<()> { let run_root = config::expand_path("~/run"); let run_internal = run_root.join("i"); if !run_root.exists() { fs::create_dir_all(&run_root) .with_context(|| format!("failed to create {}", run_root.display()))?; println!("✅ created run root: {}", run_root.display()); } else { println!("✅ run root ready: {}", run_root.display()); } if !run_internal.exists() { fs::create_dir_all(&run_internal) .with_context(|| format!("failed to create {}", run_internal.display()))?; println!("✅ created internal run root: {}", run_internal.display()); } else { println!("✅ internal run root ready: {}", run_internal.display()); } if run_root.join("flow.toml").exists() { println!("✅ run public repo detected"); } else { println!("ℹ️ run public repo not detected at {}", run_root.display()); } if run_internal.join("flow.toml").exists() { println!("✅ run internal repo detected"); } else { println!( "ℹ️ run internal repo not detected at {}", run_internal.display() ); } println!( "ℹ️ run shortcuts: f r <task>, f ri <task>, f rp <project> <task>, f rip <project> <task>" ); Ok(()) } fn ensure_fish_shell() -> Result<()> { let shell = env::var("SHELL").unwrap_or_default(); if !shell.contains("fish") { let fish = which::which("fish") .context("fish is required; install it and ensure it is on PATH")?; bail!("fish shell required. Run:\n chsh -s {}", fish.display()); } Ok(()) } fn ensure_fish_flow_init() -> Result<()> { let config_path = fish_config_path()?; let content = fs::read_to_string(&config_path).unwrap_or_default(); if content.contains("# flow:start") { return Ok(()); } println!( "⚠ flow fish integration missing in {}. Run: f shell-init fish", config_path.display() ); Ok(()) } fn ensure_gitignore() -> Result<()> { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let Some(flow_path) = find_flow_toml_upwards(&cwd) else { return Ok(()); }; let root = flow_path.parent().unwrap_or(&cwd); if !root.join(".git").exists() { return Ok(()); } add_gitignore_entry(root, ".ai/todos/*.bike")?; add_gitignore_entry(root, ".ai/review-log.jsonl")?; Ok(()) } fn ensure_ai_server() -> Result<()> { let keys = config::global_env_keys(); let mut resolved: HashMap<String, String> = HashMap::new(); let mut missing = Vec::new(); for key in &keys { if let Ok(value) = env::var(key) { if !value.trim().is_empty() { resolved.insert(key.clone(), value); continue; } } missing.push(key.clone()); } if !missing.is_empty() { if let Ok(vars) = flow_env::fetch_personal_env_vars(&missing) { for (key, value) in vars { if !value.trim().is_empty() { resolved.insert(key, value); } } } } let url = resolved.get("AI_SERVER_URL").cloned().unwrap_or_default(); let model = resolved.get("AI_SERVER_MODEL").cloned().unwrap_or_default(); let token = resolved.get("AI_SERVER_TOKEN").cloned().unwrap_or_default(); if url.trim().is_empty() { println!("⚠️ AI server env not configured (AI_SERVER_URL)."); println!(" Set it once: f env set --personal AI_SERVER_URL=http://127.0.0.1:7331"); return Ok(()); } let base = base_ai_url(&url); let client = http_client::blocking_with_timeout(Duration::from_millis(800)) .context("failed to create http client")?; let health_url = format!("{}/health", base); let mut ok = client .get(&health_url) .send() .map(|resp| resp.status().is_success()) .unwrap_or(false); if !ok { let models_url = format!("{}/v1/models", base); ok = client .get(&models_url) .send() .map(|resp| resp.status().is_success()) .unwrap_or(false); } if ok { println!("✅ AI server reachable at {}", base); } else { println!("⚠️ AI server not reachable at {}", base); println!(" Start it with your ai server repo (e.g. f daemon start ai-server)."); } if model.trim().is_empty() { println!( "⚠️ AI_SERVER_MODEL not set. Example: f env set --personal AI_SERVER_MODEL=zai-glm-4.7" ); } if token.trim().is_empty() { println!("ℹ️ AI_SERVER_TOKEN not set (ok if server is open)."); } Ok(()) } fn ensure_unhash() -> Result<()> { match which::which("unhash") { Ok(path) => { println!("✅ unhash binary found at {}", path.display()); } Err(_) => { println!("⚠️ unhash not found on PATH."); println!(" Install with: cd ~/code/unhash && f deploy"); return Ok(()); } } let key = env::var("UNHASH_KEY").ok().filter(|v| !v.trim().is_empty()); let key = match key { Some(value) => Some(value), None => flow_env::get_personal_env_var("UNHASH_KEY").ok().flatten(), }; match key { Some(value) => { if is_valid_unhash_key(&value) { println!("✅ UNHASH_KEY configured (env or flow env)"); } else { println!("⚠️ UNHASH_KEY is invalid (expected 32-byte base64 or hex)."); println!(" Fix with: unhash keygen | f env set UNHASH_KEY=..."); } } None => { println!("⚠️ UNHASH_KEY not set."); println!(" Run: unhash health --setup"); } } Ok(()) } fn ensure_rise_health() -> Result<()> { let rise_bin = match which::which("rise") { Ok(path) => path, Err(_) => { println!("ℹ️ rise not installed; skipping."); return Ok(()); } }; let rise_root = config::expand_path("~/code/rise"); if !rise_root.exists() { println!( "ℹ️ rise repo not found at {}; skipping.", rise_root.display() ); return Ok(()); } let supports_health = Command::new(&rise_bin) .arg("help") .output() .ok() .and_then(|output| { let mut combined = String::from_utf8_lossy(&output.stdout).to_string(); combined.push_str(&String::from_utf8_lossy(&output.stderr)); Some(combined.contains("rise health")) }) .unwrap_or(false); if !supports_health { println!("ℹ️ rise health not available; skipping."); return Ok(()); } let status = Command::new(&rise_bin) .arg("health") .current_dir(&rise_root) .status(); match status { Ok(status) if status.success() => { println!("✅ rise health ok"); } Ok(status) => { println!( "⚠️ rise health failed (exit {}).", status.code().unwrap_or(-1) ); } Err(err) => { println!("⚠️ failed to run rise health: {}", err); } } Ok(()) } fn ensure_linsa_base_health() -> Result<()> { let base_root = config::expand_path("~/code/org/linsa/base"); if !base_root.exists() { println!("ℹ️ ~/code/org/linsa/base not installed; skipping."); return Ok(()); } if base_root.join("flow.toml").exists() { println!("✅ linsa/base found at {}", base_root.display()); } else { println!( "⚠️ linsa/base found but flow.toml missing: {}", base_root.display() ); } Ok(()) } fn ensure_zerg_ai_health() -> Result<()> { let zerg_root = config::expand_path("~/code/zerg/ai"); if !zerg_root.exists() { println!("ℹ️ ~/code/zerg/ai not installed; skipping."); return Ok(()); } let url = env::var("ZERG_AI_URL") .ok() .filter(|v| !v.trim().is_empty()) .or_else(|| { env::var("AI_SERVER_URL") .ok() .filter(|v| !v.trim().is_empty()) }) .unwrap_or_else(|| "http://127.0.0.1:7331".to_string()); let base = base_ai_url(&url); let client = http_client::blocking_with_timeout(Duration::from_millis(800)) .context("failed to create http client")?; let health_url = format!("{}/health", base); let ok = client .get(&health_url) .send() .map(|resp| resp.status().is_success()) .unwrap_or(false); if ok { println!("✅ zerg/ai reachable at {}", base); } else { println!("⚠️ zerg/ai not reachable at {}", base); } Ok(()) } fn is_valid_unhash_key(raw: &str) -> bool { if let Ok(bytes) = BASE64_STD.decode(raw.trim()) { if bytes.len() == 32 { return true; } } if let Some(bytes) = decode_hex(raw.trim()) { if bytes.len() == 32 { return true; } } false } fn decode_hex(input: &str) -> Option<Vec<u8>> { let bytes = input.as_bytes(); if bytes.len() % 2 != 0 { return None; } let mut out = Vec::with_capacity(bytes.len() / 2); let mut i = 0; while i < bytes.len() { let hi = hex_value(bytes[i])?; let lo = hex_value(bytes[i + 1])?; out.push((hi << 4) | lo); i += 2; } Some(out) } fn hex_value(byte: u8) -> Option<u8> { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } fn base_ai_url(url: &str) -> String { let trimmed = url.trim_end_matches('/'); if let Some(idx) = trimmed.find("/v1/") { return trimmed[..idx].to_string(); } trimmed.to_string() } fn find_flow_toml_upwards(start: &PathBuf) -> Option<PathBuf> { let mut current = start.as_path(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } current = current.parent()?; } } fn fish_config_path() -> Result<PathBuf> { let home = dirs::home_dir().context("failed to resolve home directory")?; Ok(home.join("config").join("fish").join("config.fish")) } ================================================ FILE: src/help_full.json ================================================ {"version":"0.1.3","entries":[{"command":"f search","short":null,"long":null,"description":"Fuzzy search global commands/tasks without a project flow.toml.","entry_type":"subcommand"},{"command":"f global","short":null,"long":null,"description":"Run tasks from the global flow config.","entry_type":"subcommand"},{"command":"f global","short":"-l","long":"--list","description":"List global tasks","entry_type":"flag"},{"command":"f global list","short":null,"long":null,"description":"List global tasks","entry_type":"subcommand"},{"command":"f global run","short":null,"long":null,"description":"Run a global task by name","entry_type":"subcommand"},{"command":"f global match","short":null,"long":null,"description":"Match a query against global tasks (LM Studio)","entry_type":"subcommand"},{"command":"f global match","short":null,"long":"--model","description":"LM Studio model to use (defaults to qwen3-8b)","entry_type":"flag"},{"command":"f global match","short":null,"long":"--port","description":"LM Studio API port (defaults to 1234)","entry_type":"flag"},{"command":"f global match","short":"-n","long":"--dry-run","description":"Only show the match without running the task","entry_type":"flag"},{"command":"f hub","short":null,"long":null,"description":"Ensure the background hub daemon is running (spawns it if missing).","entry_type":"subcommand"},{"command":"f hub","short":null,"long":"--host","description":"Hostname or IP address of the hub daemon","entry_type":"flag"},{"command":"f hub","short":null,"long":"--port","description":"TCP port for the daemon's HTTP interface","entry_type":"flag"},{"command":"f hub","short":null,"long":"--config","description":"Optional path to the lin hub config (defaults to lin's built-in lookup)","entry_type":"flag"},{"command":"f hub","short":null,"long":"--no-ui","description":"Skip launching the hub TUI after ensuring the daemon is running","entry_type":"flag"},{"command":"f hub","short":null,"long":"--docs-hub","description":"Also start the docs hub (Next.js dev server)","entry_type":"flag"},{"command":"f hub start","short":null,"long":null,"description":"Start or ensure the hub daemon is running","entry_type":"subcommand"},{"command":"f hub stop","short":null,"long":null,"description":"Stop the hub daemon if it was started by flow","entry_type":"subcommand"},{"command":"f init","short":null,"long":null,"description":"Scaffold a new flow.toml in the current directory.","entry_type":"subcommand"},{"command":"f init","short":null,"long":"--path","description":"Where to write the scaffolded flow.toml (defaults to ./flow.toml)","entry_type":"flag"},{"command":"f shell-init","short":null,"long":null,"description":"Output shell integration script.","entry_type":"subcommand"},{"command":"f shell","short":null,"long":null,"description":"Manage shell integration.","entry_type":"subcommand"},{"command":"f shell reset","short":null,"long":null,"description":"Refresh the current shell session","entry_type":"subcommand"},{"command":"f shell fix-terminal","short":null,"long":null,"description":"Disable fish terminal query to avoid PDA warning","entry_type":"subcommand"},{"command":"f new","short":null,"long":null,"description":"Create a new project from a template.","entry_type":"subcommand"},{"command":"f new","short":null,"long":"--dry-run","description":"Show what would change without writing","entry_type":"flag"},{"command":"f home","short":null,"long":null,"description":"Home setup and config repo management.","entry_type":"subcommand"},{"command":"f home","short":null,"long":"--internal","description":"Optional internal config repo URL (cloned into ~/config/i)","entry_type":"flag"},{"command":"f home setup","short":null,"long":null,"description":"Guide home setup and validate GitHub access","entry_type":"subcommand"},{"command":"f archive","short":null,"long":null,"description":"Archive the current project to ~/archive/code.","entry_type":"subcommand"},{"command":"f doctor","short":null,"long":null,"description":"Verify required tools and shell integrations.","entry_type":"subcommand"},{"command":"f health","short":null,"long":null,"description":"Ensure your system matches Flow's expectations.","entry_type":"subcommand"},{"command":"f invariants","short":null,"long":null,"description":"Check project invariants from flow.toml against working tree or staged changes.","entry_type":"subcommand"},{"command":"f invariants","short":null,"long":"--staged","description":"Only check staged changes (default: check all changes vs HEAD)","entry_type":"flag"},{"command":"f tasks","short":null,"long":null,"description":"Fuzzy search task history or list available tasks.","entry_type":"subcommand"},{"command":"f tasks list","short":null,"long":null,"description":"List tasks from the current project flow.toml","entry_type":"subcommand"},{"command":"f tasks list","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f tasks list","short":null,"long":"--dupes","description":"Show only duplicate task names and their scopes","entry_type":"flag"},{"command":"f tasks dupes","short":null,"long":null,"description":"Show duplicate task names discovered across nested flow.toml files","entry_type":"subcommand"},{"command":"f tasks dupes","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f tasks init-ai","short":null,"long":null,"description":"Initialize AI task directory with a MoonBit starter task","entry_type":"subcommand"},{"command":"f tasks init-ai","short":null,"long":"--root","description":"Root directory where .ai/tasks should be created","entry_type":"flag"},{"command":"f tasks init-ai","short":null,"long":"--force","description":"Overwrite starter file if it already exists","entry_type":"flag"},{"command":"f tasks build-ai","short":null,"long":null,"description":"Prebuild and cache a specific AI task binary","entry_type":"subcommand"},{"command":"f tasks build-ai","short":null,"long":"--root","description":"Root directory used for .ai/tasks discovery","entry_type":"flag"},{"command":"f tasks build-ai","short":null,"long":"--force","description":"Force rebuild even if a cached artifact exists","entry_type":"flag"},{"command":"f tasks run-ai","short":null,"long":null,"description":"Run a specific AI task with optional cache/daemon execution","entry_type":"subcommand"},{"command":"f tasks run-ai","short":null,"long":"--root","description":"Root directory used for .ai/tasks discovery","entry_type":"flag"},{"command":"f tasks run-ai","short":null,"long":"--daemon","description":"Run through the AI task daemon","entry_type":"flag"},{"command":"f tasks run-ai","short":null,"long":"--no-cache","description":"Disable binary cache and use direct moon run","entry_type":"flag"},{"command":"f tasks daemon","short":null,"long":null,"description":"Manage the AI task daemon","entry_type":"subcommand"},{"command":"f tasks daemon start","short":null,"long":null,"description":"Start task daemon in the background","entry_type":"subcommand"},{"command":"f tasks daemon stop","short":null,"long":null,"description":"Stop task daemon","entry_type":"subcommand"},{"command":"f tasks daemon status","short":null,"long":null,"description":"Show task daemon status","entry_type":"subcommand"},{"command":"f fast","short":null,"long":null,"description":"Run an AI task via the low-latency fast client path.","entry_type":"subcommand"},{"command":"f fast","short":null,"long":"--root","description":"Root directory used for .ai/tasks discovery","entry_type":"flag"},{"command":"f fast","short":null,"long":"--no-cache","description":"Disable binary cache and use direct moon run","entry_type":"flag"},{"command":"f up","short":null,"long":null,"description":"Bring a project up using lifecycle conventions.","entry_type":"subcommand"},{"command":"f up","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f down","short":null,"long":null,"description":"Bring a project down using lifecycle conventions.","entry_type":"subcommand"},{"command":"f down","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f ai-test-new","short":null,"long":null,"description":"Create a local AI scratch test file under .ai/test.","entry_type":"subcommand"},{"command":"f ai-test-new","short":null,"long":"--dir","description":"Base scratch test directory, relative to project root","entry_type":"flag"},{"command":"f ai-test-new","short":null,"long":"--spec","description":"Use `.spec.ts` instead of `.test.ts`","entry_type":"flag"},{"command":"f ai-test-new","short":null,"long":"--force","description":"Overwrite existing file if present","entry_type":"flag"},{"command":"f last-cmd","short":null,"long":null,"description":"Show the last task input and its output/error.","entry_type":"subcommand"},{"command":"f last-cmd-full","short":null,"long":null,"description":"Show the last task run (command, status, and output) recorded by flow.","entry_type":"subcommand"},{"command":"f fish-last","short":null,"long":null,"description":"Show the last fish shell command and output (from fish io-trace).","entry_type":"subcommand"},{"command":"f fish-last-full","short":null,"long":null,"description":"Show full details of the last fish shell command.","entry_type":"subcommand"},{"command":"f fish-install","short":null,"long":null,"description":"Install traced fish shell (fish fork with always-on I/O tracing).","entry_type":"subcommand"},{"command":"f fish-install","short":null,"long":"--source","description":"Path to fish-shell source repo (auto-detected if not set)","entry_type":"flag"},{"command":"f fish-install","short":null,"long":"--bin-dir","description":"Install directory for the fish binary (defaults to ~/.local/bin)","entry_type":"flag"},{"command":"f fish-install","short":null,"long":"--force","description":"Force reinstall even if already installed","entry_type":"flag"},{"command":"f fish-install","short":"-y","long":"--yes","description":"Skip confirmation prompt","entry_type":"flag"},{"command":"f rerun","short":null,"long":null,"description":"Re-run the last task executed in this project.","entry_type":"subcommand"},{"command":"f rerun","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f ps","short":null,"long":null,"description":"List running flow processes for the current project.","entry_type":"subcommand"},{"command":"f ps","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f ps","short":null,"long":"--all","description":"Show all running flow processes across all projects","entry_type":"flag"},{"command":"f kill","short":null,"long":null,"description":"Stop running flow processes.","entry_type":"subcommand"},{"command":"f kill","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f kill","short":null,"long":"--pid","description":"Kill by PID directly","entry_type":"flag"},{"command":"f kill","short":null,"long":"--all","description":"Kill all processes for this project","entry_type":"flag"},{"command":"f kill","short":"-f","long":"--force","description":"Force kill (SIGKILL) without graceful shutdown","entry_type":"flag"},{"command":"f kill","short":null,"long":"--timeout","description":"Timeout in seconds before sending SIGKILL (default: 5)","entry_type":"flag"},{"command":"f logs","short":null,"long":null,"description":"View logs from running or recent tasks.","entry_type":"subcommand"},{"command":"f logs","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f logs","short":"-f","long":"--follow","description":"Follow the log in real-time (like tail -f)","entry_type":"flag"},{"command":"f logs","short":"-n","long":"--lines","description":"Number of lines to show from the end","entry_type":"flag"},{"command":"f logs","short":null,"long":"--all","description":"Show logs for all projects","entry_type":"flag"},{"command":"f logs","short":"-l","long":"--list","description":"List available log files instead of showing content","entry_type":"flag"},{"command":"f logs","short":"-p","long":"--project","description":"Look up logs by registered project name instead of config path","entry_type":"flag"},{"command":"f logs","short":"-q","long":"--quiet","description":"Suppress headers, output only log content","entry_type":"flag"},{"command":"f logs","short":null,"long":"--task-id","description":"Hub task ID to fetch logs for (from delegated tasks)","entry_type":"flag"},{"command":"f trace","short":null,"long":null,"description":"Quick traces for AI + task runs from jazz2 state.","entry_type":"subcommand"},{"command":"f trace","short":"-n","long":"--limit","description":"Max rows per source (default: 40)","entry_type":"flag"},{"command":"f trace","short":"-f","long":"--follow","description":"Follow and stream new entries","entry_type":"flag"},{"command":"f trace","short":null,"long":"--project","description":"Filter by project path substring","entry_type":"flag"},{"command":"f trace","short":null,"long":"--source","description":"Which source to show: all, tasks, ai","entry_type":"flag"},{"command":"f trace session","short":null,"long":null,"description":"Show full history of the last active AI session for a project path","entry_type":"subcommand"},{"command":"f analytics","short":null,"long":null,"description":"Manage anonymous usage analytics preferences and local queue.","entry_type":"subcommand"},{"command":"f analytics status","short":null,"long":null,"description":"Show analytics status and queue metadata","entry_type":"subcommand"},{"command":"f analytics enable","short":null,"long":null,"description":"Enable anonymous usage analytics","entry_type":"subcommand"},{"command":"f analytics disable","short":null,"long":null,"description":"Disable anonymous usage analytics","entry_type":"subcommand"},{"command":"f analytics export","short":null,"long":null,"description":"Print queued analytics events","entry_type":"subcommand"},{"command":"f analytics purge","short":null,"long":null,"description":"Delete all queued analytics events","entry_type":"subcommand"},{"command":"f projects","short":null,"long":null,"description":"List registered projects.","entry_type":"subcommand"},{"command":"f sessions","short":null,"long":null,"description":"Fuzzy search AI sessions across all projects and copy context.","entry_type":"subcommand"},{"command":"f sessions","short":"-p","long":"--provider","description":"Filter by provider (claude, codex, cursor, or all)","entry_type":"flag"},{"command":"f sessions","short":"-c","long":"--count","description":"Number of exchanges to copy (default: all since checkpoint)","entry_type":"flag"},{"command":"f sessions","short":"-l","long":"--list","description":"Show sessions but don't copy to clipboard","entry_type":"flag"},{"command":"f sessions","short":"-f","long":"--full","description":"Get full session context, ignoring checkpoints","entry_type":"flag"},{"command":"f sessions","short":null,"long":"--summarize","description":"Generate summaries for stale sessions (uses Gemini)","entry_type":"flag"},{"command":"f sessions","short":null,"long":"--handoff","description":"Condense the selected session into a handoff summary (uses Gemini)","entry_type":"flag"},{"command":"f active","short":null,"long":null,"description":"Show or set the active project.","entry_type":"subcommand"},{"command":"f active","short":"-c","long":"--clear","description":"Clear the active project","entry_type":"flag"},{"command":"f server","short":null,"long":null,"description":"Start the flow HTTP server for log ingestion and queries.","entry_type":"subcommand"},{"command":"f server","short":null,"long":"--host","description":"Host to bind the server to","entry_type":"flag"},{"command":"f server","short":null,"long":"--port","description":"Port for the HTTP server","entry_type":"flag"},{"command":"f server foreground","short":null,"long":null,"description":"Start the server in the foreground","entry_type":"subcommand"},{"command":"f server stop","short":null,"long":null,"description":"Stop the background server","entry_type":"subcommand"},{"command":"f web","short":null,"long":null,"description":"Open the Flow web UI for this project.","entry_type":"subcommand"},{"command":"f web","short":null,"long":"--port","description":"Port to serve the web UI on","entry_type":"flag"},{"command":"f web","short":null,"long":"--host","description":"Host to bind the web UI server to","entry_type":"flag"},{"command":"f match","short":null,"long":null,"description":"Match a natural language query to a task using LM Studio.","entry_type":"subcommand"},{"command":"f match","short":null,"long":"--model","description":"LM Studio model to use (defaults to qwen3-8b)","entry_type":"flag"},{"command":"f match","short":null,"long":"--port","description":"LM Studio API port (defaults to 1234)","entry_type":"flag"},{"command":"f match","short":"-n","long":"--dry-run","description":"Only show the match without running the task","entry_type":"flag"},{"command":"f ask","short":null,"long":null,"description":"Ask the AI server to suggest a task or Flow command.","entry_type":"subcommand"},{"command":"f ask","short":null,"long":"--model","description":"AI server model to use (defaults to AI_SERVER_MODEL)","entry_type":"flag"},{"command":"f ask","short":null,"long":"--url","description":"AI server URL (defaults to AI_SERVER_URL or http://127.0.0.1:7331)","entry_type":"flag"},{"command":"f branches","short":null,"long":null,"description":"List and search git branches quickly.","entry_type":"subcommand"},{"command":"f branches list","short":null,"long":null,"description":"List recent branches","entry_type":"subcommand"},{"command":"f branches list","short":null,"long":"--remote","description":"Include remote branches","entry_type":"flag"},{"command":"f branches list","short":null,"long":"--limit","description":"Maximum number of branches to show","entry_type":"flag"},{"command":"f branches find","short":null,"long":null,"description":"Find branches by substring or token query","entry_type":"subcommand"},{"command":"f branches find","short":null,"long":"--remote","description":"Include remote branches","entry_type":"flag"},{"command":"f branches find","short":null,"long":"--limit","description":"Maximum number of matches to show","entry_type":"flag"},{"command":"f branches find","short":null,"long":"--switch","description":"Switch to the top match automatically","entry_type":"flag"},{"command":"f branches ai","short":null,"long":null,"description":"Use AI to map a natural language query to the best branch","entry_type":"subcommand"},{"command":"f branches ai","short":null,"long":"--remote","description":"Include remote branches","entry_type":"flag"},{"command":"f branches ai","short":null,"long":"--limit","description":"Maximum candidate branches to send to AI","entry_type":"flag"},{"command":"f branches ai","short":null,"long":"--model","description":"AI server model to use (defaults to AI_SERVER_MODEL)","entry_type":"flag"},{"command":"f branches ai","short":null,"long":"--url","description":"AI server URL (defaults to AI_SERVER_URL or http://127.0.0.1:7331)","entry_type":"flag"},{"command":"f branches ai","short":null,"long":"--switch","description":"Switch to the selected branch automatically","entry_type":"flag"},{"command":"f commit","short":null,"long":null,"description":"AI-powered commit with code review and optional GitEdit sync.","entry_type":"subcommand"},{"command":"f commit","short":"-n","long":"--no-push","description":"Skip pushing after commit","entry_type":"flag"},{"command":"f commit","short":null,"long":"--queue","description":"Queue the commit for review before pushing","entry_type":"flag"},{"command":"f commit","short":null,"long":"--no-queue","description":"Bypass commit queue and allow pushing immediately","entry_type":"flag"},{"command":"f commit","short":null,"long":"--force","description":"Force commit without queue (bypass stacked review)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--approved","description":"Commit and push immediately (bypass commit queue)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--review","description":"Open the queued commit in Rise for review after commit","entry_type":"flag"},{"command":"f commit","short":null,"long":"--sync","description":"Run synchronously (don't delegate to hub)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--context","description":"Include AI session context in code review (default: off)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--hashed","description":"Include an unhash.sh bundle/link in the commit message (opt-in)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--dry","description":"Dry run: show context that would be passed to review without committing","entry_type":"flag"},{"command":"f commit","short":null,"long":"--quick","description":"Commit immediately and run Codex review asynchronously in the background","entry_type":"flag"},{"command":"f commit","short":null,"long":"--slow","description":"Run blocking review before committing (legacy commitWithCheck behavior)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--codex","description":"Use Codex instead of Claude for code review (default: Claude)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--review-model","description":"Choose a specific review model (claude-opus, codex-high, codex-mini)","entry_type":"flag"},{"command":"f commit","short":"-m","long":"--message","description":"Custom message to include in commit (appended after author line)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--fast","description":"Fast commit with optional message (defaults to \".\")","entry_type":"flag"},{"command":"f commit","short":null,"long":"--path","description":"Stage and commit only these paths (repeatable)","entry_type":"flag"},{"command":"f commit","short":"-t","long":"--tokens","description":"Max tokens for AI session context (default: 1000)","entry_type":"flag"},{"command":"f commit","short":null,"long":"--skip-quality","description":"Skip all quality gates for this commit","entry_type":"flag"},{"command":"f commit","short":null,"long":"--skip-docs","description":"Skip documentation requirements only","entry_type":"flag"},{"command":"f commit","short":null,"long":"--skip-tests","description":"Skip test requirements only","entry_type":"flag"},{"command":"f commit-queue","short":null,"long":null,"description":"Manage the commit review queue.","entry_type":"subcommand"},{"command":"f commit-queue list","short":null,"long":null,"description":"List queued commits","entry_type":"subcommand"},{"command":"f commit-queue show","short":null,"long":null,"description":"Show details for a queued commit","entry_type":"subcommand"},{"command":"f commit-queue open","short":null,"long":null,"description":"Open the queued commit diff in Rise app (multi-file diff UI)","entry_type":"subcommand"},{"command":"f commit-queue diff","short":null,"long":null,"description":"Print the full diff for a queued commit to stdout","entry_type":"subcommand"},{"command":"f commit-queue review","short":null,"long":null,"description":"Re-run AI review for queued commits and refresh queue/todo metadata","entry_type":"subcommand"},{"command":"f commit-queue review","short":null,"long":"--all","description":"Review all queued commits across branches","entry_type":"flag"},{"command":"f commit-queue approve","short":null,"long":null,"description":"Approve a queued commit and push it","entry_type":"subcommand"},{"command":"f commit-queue approve","short":null,"long":"--all","description":"Approve all queued commits on the current branch (push once)","entry_type":"flag"},{"command":"f commit-queue approve","short":null,"long":"--queue-if-missing","description":"If hash is not queued but exists in git history, queue it first","entry_type":"flag"},{"command":"f commit-queue approve","short":null,"long":"--mark-reviewed","description":"Mark an auto-queued commit as manually reviewed","entry_type":"flag"},{"command":"f commit-queue approve","short":"-f","long":"--force","description":"Push even if the commit is not at HEAD","entry_type":"flag"},{"command":"f commit-queue approve","short":null,"long":"--allow-issues","description":"Allow pushing even if the queued commit has review issues recorded","entry_type":"flag"},{"command":"f commit-queue approve","short":null,"long":"--allow-unreviewed","description":"Allow pushing even if the review timed out or is missing","entry_type":"flag"},{"command":"f commit-queue approve-all","short":null,"long":null,"description":"Approve all queued commits on the current branch (push once)","entry_type":"subcommand"},{"command":"f commit-queue approve-all","short":"-f","long":"--force","description":"Push even if the branch is behind its remote","entry_type":"flag"},{"command":"f commit-queue approve-all","short":null,"long":"--allow-issues","description":"Allow pushing even if some queued commits have review issues recorded","entry_type":"flag"},{"command":"f commit-queue approve-all","short":null,"long":"--allow-unreviewed","description":"Allow pushing even if some queued commits have review timed out / missing","entry_type":"flag"},{"command":"f commit-queue drop","short":null,"long":null,"description":"Remove a commit from the queue without pushing","entry_type":"subcommand"},{"command":"f commit-queue pr-create","short":null,"long":null,"description":"Create or update a GitHub PR for a queued commit (pushes a bookmark/branch as the PR head)","entry_type":"subcommand"},{"command":"f commit-queue pr-create","short":null,"long":"--base","description":"Base branch for the PR (default: main)","entry_type":"flag"},{"command":"f commit-queue pr-create","short":null,"long":"--draft","description":"Create as a draft PR","entry_type":"flag"},{"command":"f commit-queue pr-create","short":null,"long":"--open","description":"Open PR in browser after creating/finding it","entry_type":"flag"},{"command":"f commit-queue pr-open","short":null,"long":null,"description":"Open the PR for a queued commit in the browser (creates it if missing)","entry_type":"subcommand"},{"command":"f commit-queue pr-open","short":null,"long":"--base","description":"Base branch for the PR if it needs to be created (default: main)","entry_type":"flag"},{"command":"f reviews-todo","short":null,"long":null,"description":"Manage deferred deep-review todos for queued commits.","entry_type":"subcommand"},{"command":"f reviews-todo list","short":null,"long":null,"description":"List pending review todos with priority indicators","entry_type":"subcommand"},{"command":"f reviews-todo show","short":null,"long":null,"description":"Show details for a review todo","entry_type":"subcommand"},{"command":"f reviews-todo done","short":null,"long":null,"description":"Mark a review todo as resolved","entry_type":"subcommand"},{"command":"f reviews-todo fix","short":null,"long":null,"description":"Auto-fix a review todo via Codex","entry_type":"subcommand"},{"command":"f reviews-todo fix","short":null,"long":"--all","description":"Fix all open review todos","entry_type":"flag"},{"command":"f reviews-todo codex","short":null,"long":null,"description":"Run Codex deep review for queued commits","entry_type":"subcommand"},{"command":"f reviews-todo codex","short":null,"long":"--all","description":"Review all queued commits across branches","entry_type":"flag"},{"command":"f reviews-todo approve-all","short":null,"long":null,"description":"Approve all queued commits once deep review todos are resolved","entry_type":"subcommand"},{"command":"f reviews-todo approve-all","short":"-f","long":"--force","description":"Push even if the branch is behind its remote","entry_type":"flag"},{"command":"f reviews-todo approve-all","short":null,"long":"--allow-issues","description":"Allow pushing even if some queued commits have review issues recorded","entry_type":"flag"},{"command":"f reviews-todo approve-all","short":null,"long":"--allow-unreviewed","description":"Allow pushing even if some queued commits have review timed out / missing","entry_type":"flag"},{"command":"f pr","short":null,"long":null,"description":"Create a GitHub PR from current changes or a queued commit.","entry_type":"subcommand"},{"command":"f pr","short":null,"long":"--base","description":"Base branch for the PR (default: main)","entry_type":"flag"},{"command":"f pr","short":null,"long":"--draft","description":"Create as a draft PR","entry_type":"flag"},{"command":"f pr","short":null,"long":"--no-open","description":"Do not open the PR in browser after creating/finding it","entry_type":"flag"},{"command":"f pr","short":null,"long":"--no-commit","description":"Skip creating a new commit; use an existing queued commit","entry_type":"flag"},{"command":"f pr","short":null,"long":"--hash","description":"Specific queued commit hash to use (short or full)","entry_type":"flag"},{"command":"f pr","short":null,"long":"--path","description":"Stage and commit only these paths before creating PR (repeatable)","entry_type":"flag"},{"command":"f gitignore","short":null,"long":null,"description":"Manage personal tooling ignore policy across repos.","entry_type":"subcommand"},{"command":"f gitignore audit","short":null,"long":null,"description":"Audit .gitignore files for blocked personal-tooling patterns","entry_type":"subcommand"},{"command":"f gitignore audit","short":null,"long":"--root","description":"Root directory to scan for repositories (defaults to policy repos_roots, then ~/repos)","entry_type":"flag"},{"command":"f gitignore audit","short":null,"long":"--all","description":"Include repos owned by allowed owners","entry_type":"flag"},{"command":"f gitignore fix","short":null,"long":null,"description":"Remove blocked personal-tooling patterns from .gitignore files","entry_type":"subcommand"},{"command":"f gitignore fix","short":null,"long":"--root","description":"Root directory to scan for repositories (defaults to policy repos_roots, then ~/repos)","entry_type":"flag"},{"command":"f gitignore fix","short":null,"long":"--all","description":"Include repos owned by allowed owners","entry_type":"flag"},{"command":"f gitignore policy-init","short":null,"long":null,"description":"Create ~/.config/flow/gitignore-policy.toml with defaults","entry_type":"subcommand"},{"command":"f gitignore policy-init","short":null,"long":"--force","description":"Overwrite an existing policy file","entry_type":"flag"},{"command":"f gitignore setup-global","short":null,"long":null,"description":"Configure a global git excludes file with blocked personal-tooling patterns","entry_type":"subcommand"},{"command":"f gitignore setup-global","short":null,"long":"--print-only","description":"Print target path/entries without writing changes","entry_type":"flag"},{"command":"f gitignore policy-path","short":null,"long":null,"description":"Print the active policy file path","entry_type":"subcommand"},{"command":"f review","short":null,"long":null,"description":"Open queued commits for review in Rise.","entry_type":"subcommand"},{"command":"f review latest","short":null,"long":null,"description":"Open the latest queued commit in Rise","entry_type":"subcommand"},{"command":"f review copy","short":null,"long":null,"description":"Copy a ready-to-send review prompt for a queued commit to clipboard","entry_type":"subcommand"},{"command":"f undo","short":null,"long":null,"description":"Undo the last undoable action (commit, push).","entry_type":"subcommand"},{"command":"f undo","short":"-n","long":"--dry-run","description":"Dry run - show what would be undone without doing it","entry_type":"flag"},{"command":"f undo","short":"-f","long":"--force","description":"Force undo even if it requires force push","entry_type":"flag"},{"command":"f undo show","short":null,"long":null,"description":"Show the last undoable action","entry_type":"subcommand"},{"command":"f undo list","short":null,"long":null,"description":"List recent undoable actions","entry_type":"subcommand"},{"command":"f undo list","short":"-l","long":"--limit","description":"Maximum number of actions to show","entry_type":"flag"},{"command":"f fix","short":null,"long":null,"description":"Fix issues in the repo with help from Hive.","entry_type":"subcommand"},{"command":"f fix","short":null,"long":"--no-unroll","description":"Skip unrolling the last commit","entry_type":"flag"},{"command":"f fix","short":null,"long":"--stash","description":"Stash local changes before unrolling, then restore after","entry_type":"flag"},{"command":"f fix","short":null,"long":"--agent","description":"Hive agent name to run (default: shell)","entry_type":"flag"},{"command":"f fix","short":null,"long":"--no-agent","description":"Skip running Hive agent (only unroll)","entry_type":"flag"},{"command":"f fixup","short":null,"long":null,"description":"Fix common TOML syntax errors in flow.toml.","entry_type":"subcommand"},{"command":"f fixup","short":null,"long":"--config","description":"Path to the flow.toml to fix (defaults to ./flow.toml)","entry_type":"flag"},{"command":"f fixup","short":"-n","long":"--dry-run","description":"Only show what would be fixed without making changes","entry_type":"flag"},{"command":"f changes","short":null,"long":null,"description":"Share or apply git diffs without remotes.","entry_type":"subcommand"},{"command":"f changes current-diff","short":null,"long":null,"description":"Print the current git diff for sharing.","entry_type":"subcommand"},{"command":"f changes accept","short":null,"long":null,"description":"Apply a diff to the current repo.","entry_type":"subcommand"},{"command":"f changes accept","short":"-f","long":"--file","description":"Read diff from a file path","entry_type":"flag"},{"command":"f diff","short":null,"long":null,"description":"Create or unpack a shareable diff bundle.","entry_type":"subcommand"},{"command":"f diff","short":null,"long":"--env","description":"Include specific env vars from local personal env store. Examples: --env CEREBRAS_API_KEY --env CEREBRAS_MODEL --env CEREBRAS_API_KEY,CEREBRAS_MODEL --env='[\\\"CEREBRAS_API_KEY\\\",\\\"CEREBRAS_MODEL\\\"]'","entry_type":"flag"},{"command":"f hash","short":null,"long":null,"description":"Hash files or sessions with unhash and copy a share link.","entry_type":"subcommand"},{"command":"f daemon","short":null,"long":null,"description":"Manage background daemons (start, stop, status).","entry_type":"subcommand"},{"command":"f daemon start","short":null,"long":null,"description":"Start a daemon by name","entry_type":"subcommand"},{"command":"f daemon stop","short":null,"long":null,"description":"Stop a running daemon","entry_type":"subcommand"},{"command":"f daemon restart","short":null,"long":null,"description":"Restart a daemon (stop then start)","entry_type":"subcommand"},{"command":"f daemon status","short":null,"long":null,"description":"Show status of all configured daemons","entry_type":"subcommand"},{"command":"f daemon list","short":null,"long":null,"description":"List available daemons","entry_type":"subcommand"},{"command":"f supervisor","short":null,"long":null,"description":"Run the Flow supervisor (daemon manager).","entry_type":"subcommand"},{"command":"f supervisor","short":null,"long":"--socket","description":"Socket path for supervisor IPC (defaults to ~/.config/flow/supervisor.sock)","entry_type":"flag"},{"command":"f supervisor start","short":null,"long":null,"description":"Start the supervisor in the background","entry_type":"subcommand"},{"command":"f supervisor start","short":null,"long":"--boot","description":"Start boot daemons in addition to autostart daemons","entry_type":"flag"},{"command":"f supervisor run","short":null,"long":null,"description":"Run the supervisor in the foreground (blocking)","entry_type":"subcommand"},{"command":"f supervisor run","short":null,"long":"--boot","description":"Start boot daemons in addition to autostart daemons","entry_type":"flag"},{"command":"f supervisor install","short":null,"long":null,"description":"Install a macOS LaunchAgent to keep the supervisor running","entry_type":"subcommand"},{"command":"f supervisor install","short":null,"long":"--boot","description":"Start boot daemons in addition to autostart daemons","entry_type":"flag"},{"command":"f supervisor uninstall","short":null,"long":null,"description":"Remove the macOS LaunchAgent for the supervisor","entry_type":"subcommand"},{"command":"f supervisor stop","short":null,"long":null,"description":"Stop the supervisor if running","entry_type":"subcommand"},{"command":"f supervisor status","short":null,"long":null,"description":"Show supervisor status","entry_type":"subcommand"},{"command":"f ai","short":null,"long":null,"description":"Manage AI coding sessions (Claude Code).","entry_type":"subcommand"},{"command":"f ai list","short":null,"long":null,"description":"List all AI sessions for this project (Claude + Codex + Cursor)","entry_type":"subcommand"},{"command":"f ai cursor","short":null,"long":null,"description":"Cursor: inspect and read agent transcripts for this project","entry_type":"subcommand"},{"command":"f ai cursor list","short":null,"long":null,"description":"List sessions for this provider","entry_type":"subcommand"},{"command":"f ai cursor latest-id","short":null,"long":null,"description":"Print the most recent session ID for this provider","entry_type":"subcommand"},{"command":"f ai cursor latest-id","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor sessions","short":null,"long":null,"description":"List provider sessions with IDs","entry_type":"subcommand"},{"command":"f ai cursor sessions","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor sessions","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor continue","short":null,"long":null,"description":"Continue the most recent session for this provider","entry_type":"subcommand"},{"command":"f ai cursor continue","short":null,"long":"--path","description":"Project path to continue from instead of the current directory","entry_type":"flag"},{"command":"f ai cursor new","short":null,"long":null,"description":"Start a new session (ignores existing sessions)","entry_type":"subcommand"},{"command":"f ai cursor resume","short":null,"long":null,"description":"Resume a session","entry_type":"subcommand"},{"command":"f ai cursor resume","short":null,"long":"--path","description":"Project path to resume from instead of the current directory","entry_type":"flag"},{"command":"f ai cursor connect","short":null,"long":null,"description":"Connect to an existing Codex session selected by natural-language query","entry_type":"subcommand"},{"command":"f ai cursor connect","short":null,"long":"--path","description":"Project path or repo root to search instead of the configured Codex home-session path","entry_type":"flag"},{"command":"f ai cursor connect","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai cursor connect","short":null,"long":"--json","description":"Emit machine-readable JSON for the selected session instead of resuming it","entry_type":"flag"},{"command":"f ai cursor open","short":null,"long":null,"description":"Open a Codex session with fast repo-scoped recovery and reference unrolling","entry_type":"subcommand"},{"command":"f ai cursor open","short":null,"long":"--path","description":"Project path to open from instead of the current directory","entry_type":"flag"},{"command":"f ai cursor open","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai cursor resolve","short":null,"long":null,"description":"Resolve how `f codex open` would interpret a query","entry_type":"subcommand"},{"command":"f ai cursor resolve","short":null,"long":"--path","description":"Project path to resolve from instead of the current directory","entry_type":"flag"},{"command":"f ai cursor resolve","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai cursor resolve","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor doctor","short":null,"long":null,"description":"Print effective Codex control-plane settings for this path","entry_type":"subcommand"},{"command":"f ai cursor doctor","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor doctor","short":null,"long":"--assert-runtime","description":"Exit non-zero unless wrapper transport and runtime skills are active","entry_type":"flag"},{"command":"f ai cursor doctor","short":null,"long":"--assert-schedule","description":"Exit non-zero unless the scheduled scorecard refresher is installed and loaded","entry_type":"flag"},{"command":"f ai cursor doctor","short":null,"long":"--assert-learning","description":"Exit non-zero unless Flow has grounded learning data for this target","entry_type":"flag"},{"command":"f ai cursor doctor","short":null,"long":"--assert-autonomous","description":"Exit non-zero unless runtime, schedule, and grounded learning are all active","entry_type":"flag"},{"command":"f ai cursor doctor","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor eval","short":null,"long":null,"description":"Evaluate how well Flow-guided Codex usage is working for this repo/path","entry_type":"subcommand"},{"command":"f ai cursor eval","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor eval","short":null,"long":"--limit","description":"Maximum number of recent logged events/outcomes to inspect","entry_type":"flag"},{"command":"f ai cursor eval","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":null,"description":"Enable the global Codex wrapper/runtime path so Flow features are actually used","entry_type":"subcommand"},{"command":"f ai cursor enable-global","short":null,"long":"--dry-run","description":"Show the resulting global config and actions without writing anything","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--install-launchd","description":"Also install the macOS launchd scorecard refresher","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--start-daemon","description":"Start codexd immediately after enabling the global config","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--sync-skills","description":"Sync any discovered external skill sources after enabling the config","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--full","description":"Shortcut for --install-launchd --start-daemon --sync-skills","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--minutes","description":"Launchd cadence in minutes (used with --install-launchd/--full)","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--limit","description":"Max logged events to scan per launchd run","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--max-targets","description":"Max repos to rebuild per launchd run","entry_type":"flag"},{"command":"f ai cursor enable-global","short":null,"long":"--within-hours","description":"Recent-history window for launchd cron selection","entry_type":"flag"},{"command":"f ai cursor daemon","short":null,"long":null,"description":"Manage the Flow codexd query daemon","entry_type":"subcommand"},{"command":"f ai cursor daemon start","short":null,"long":null,"description":"Start codexd under Flow supervision","entry_type":"subcommand"},{"command":"f ai cursor daemon stop","short":null,"long":null,"description":"Stop codexd","entry_type":"subcommand"},{"command":"f ai cursor daemon restart","short":null,"long":null,"description":"Restart codexd","entry_type":"subcommand"},{"command":"f ai cursor daemon status","short":null,"long":null,"description":"Show codexd status","entry_type":"subcommand"},{"command":"f ai cursor memory","short":null,"long":null,"description":"Inspect or sync the Jazz2-backed Codex memory mirror","entry_type":"subcommand"},{"command":"f ai cursor memory status","short":null,"long":null,"description":"Show memory mirror status and counts","entry_type":"subcommand"},{"command":"f ai cursor memory status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor memory sync","short":null,"long":null,"description":"Sync recent Codex skill-eval logs into the Jazz2-backed memory mirror","entry_type":"subcommand"},{"command":"f ai cursor memory sync","short":null,"long":"--limit","description":"Maximum number of recent events and outcomes to ingest","entry_type":"flag"},{"command":"f ai cursor memory sync","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor memory query","short":null,"long":null,"description":"Query compact repo/code memory facts for a path","entry_type":"subcommand"},{"command":"f ai cursor memory query","short":null,"long":"--path","description":"Project path or repo root to query","entry_type":"flag"},{"command":"f ai cursor memory query","short":null,"long":"--limit","description":"Maximum number of fact hits to include","entry_type":"flag"},{"command":"f ai cursor memory query","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor memory recent","short":null,"long":null,"description":"Show recent memory rows, optionally scoped to a repo/path","entry_type":"subcommand"},{"command":"f ai cursor memory recent","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor memory recent","short":null,"long":"--limit","description":"Maximum number of rows to print","entry_type":"flag"},{"command":"f ai cursor memory recent","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor telemetry","short":null,"long":null,"description":"Export redacted Codex workflow telemetry to configured Maple endpoints","entry_type":"subcommand"},{"command":"f ai cursor telemetry status","short":null,"long":null,"description":"Show Codex telemetry export config and current forwarder state","entry_type":"subcommand"},{"command":"f ai cursor telemetry status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor telemetry flush","short":null,"long":null,"description":"Flush recently logged Codex telemetry to configured Maple endpoints once","entry_type":"subcommand"},{"command":"f ai cursor telemetry flush","short":null,"long":"--limit","description":"Maximum number of unseen events/outcomes to export in one pass","entry_type":"flag"},{"command":"f ai cursor telemetry flush","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor trace","short":null,"long":null,"description":"Inspect Flow-managed Codex traces for the current or a specific session","entry_type":"subcommand"},{"command":"f ai cursor trace status","short":null,"long":null,"description":"Show Maple trace read status and configured credentials","entry_type":"subcommand"},{"command":"f ai cursor trace status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor trace current-session","short":null,"long":null,"description":"Inspect the trace associated with the active Flow-managed Codex session","entry_type":"subcommand"},{"command":"f ai cursor trace current-session","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f ai cursor trace current-session","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor trace inspect","short":null,"long":null,"description":"Inspect a specific trace id","entry_type":"subcommand"},{"command":"f ai cursor trace inspect","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f ai cursor trace inspect","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor skill-eval","short":null,"long":null,"description":"Build and inspect local Codex skill scorecards from Flow history","entry_type":"subcommand"},{"command":"f ai cursor skill-eval run","short":null,"long":null,"description":"Rebuild the local scorecard for this repo/path from recent Flow Codex history","entry_type":"subcommand"},{"command":"f ai cursor skill-eval run","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor skill-eval run","short":null,"long":"--limit","description":"Maximum number of recent events to use when rebuilding","entry_type":"flag"},{"command":"f ai cursor skill-eval run","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor skill-eval show","short":null,"long":null,"description":"Show the current scorecard for this repo/path","entry_type":"subcommand"},{"command":"f ai cursor skill-eval show","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor skill-eval show","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor skill-eval events","short":null,"long":null,"description":"Show recent logged skill-eval events","entry_type":"subcommand"},{"command":"f ai cursor skill-eval events","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor skill-eval events","short":null,"long":"--limit","description":"Maximum number of events to print","entry_type":"flag"},{"command":"f ai cursor skill-eval events","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor skill-eval cron","short":null,"long":null,"description":"Refresh scorecards for the most recent repos seen in Flow Codex history","entry_type":"subcommand"},{"command":"f ai cursor skill-eval cron","short":null,"long":"--limit","description":"Maximum number of logged events to scan for target repos","entry_type":"flag"},{"command":"f ai cursor skill-eval cron","short":null,"long":"--max-targets","description":"Maximum number of repo targets to rebuild in one pass","entry_type":"flag"},{"command":"f ai cursor skill-eval cron","short":null,"long":"--within-hours","description":"Only consider repos seen within this many recent hours","entry_type":"flag"},{"command":"f ai cursor skill-eval cron","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor skill-source","short":null,"long":null,"description":"Discover and sync external Codex skill sources","entry_type":"subcommand"},{"command":"f ai cursor skill-source list","short":null,"long":null,"description":"List discovered external skills available for Codex runtime injection","entry_type":"subcommand"},{"command":"f ai cursor skill-source list","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor skill-source list","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor skill-source sync","short":null,"long":null,"description":"Copy discovered external skills into ~/.codex/skills for persistent use","entry_type":"subcommand"},{"command":"f ai cursor skill-source sync","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai cursor skill-source sync","short":null,"long":"--skill","description":"Restrict sync to the named discovered skills","entry_type":"flag"},{"command":"f ai cursor skill-source sync","short":null,"long":"--force","description":"Overwrite an existing ~/.codex/skills/<name> directory","entry_type":"flag"},{"command":"f ai cursor runtime","short":null,"long":null,"description":"Inspect or manage Flow-managed Codex runtime helpers","entry_type":"subcommand"},{"command":"f ai cursor runtime show","short":null,"long":null,"description":"Show recent Flow-managed Codex runtime skill activations","entry_type":"subcommand"},{"command":"f ai cursor runtime clear","short":null,"long":null,"description":"Remove Flow-managed runtime skill state and stale symlinks","entry_type":"subcommand"},{"command":"f ai cursor runtime write-plan","short":null,"long":null,"description":"Write a markdown plan to ~/plan and print the final path","entry_type":"subcommand"},{"command":"f ai cursor runtime write-plan","short":null,"long":"--title","description":"Human-readable title used to derive the filename","entry_type":"flag"},{"command":"f ai cursor runtime write-plan","short":null,"long":"--stem","description":"Explicit filename stem to use instead of deriving from the title","entry_type":"flag"},{"command":"f ai cursor runtime write-plan","short":null,"long":"--dir","description":"Destination directory (defaults to ~/plan)","entry_type":"flag"},{"command":"f ai cursor runtime write-plan","short":null,"long":"--source-session","description":"Codex session id to append as a footer (defaults to $CODEX_THREAD_ID)","entry_type":"flag"},{"command":"f ai cursor find","short":null,"long":null,"description":"Search Codex sessions by prompt text and resume the best match","entry_type":"subcommand"},{"command":"f ai cursor find","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f ai cursor find","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai cursor findAndCopy","short":null,"long":null,"description":"Search Codex sessions by prompt text and copy the best match to clipboard","entry_type":"subcommand"},{"command":"f ai cursor findAndCopy","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f ai cursor findAndCopy","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai cursor copy","short":null,"long":null,"description":"Copy session history to clipboard","entry_type":"subcommand"},{"command":"f ai cursor context","short":null,"long":null,"description":"Copy last prompt and response to clipboard (for context passing). Usage: f ai claude context [session] [path] [count]","entry_type":"subcommand"},{"command":"f ai cursor show","short":null,"long":null,"description":"Print a cleaned session excerpt to stdout","entry_type":"subcommand"},{"command":"f ai cursor show","short":null,"long":"--path","description":"Path to project directory (default: current directory)","entry_type":"flag"},{"command":"f ai cursor show","short":null,"long":"--count","description":"Number of exchanges to include (default: 12)","entry_type":"flag"},{"command":"f ai cursor show","short":null,"long":"--full","description":"Print the full cleaned transcript instead of just the trailing exchanges","entry_type":"flag"},{"command":"f ai cursor recover","short":null,"long":null,"description":"Recover recent Codex session context for a repo or subpath","entry_type":"subcommand"},{"command":"f ai cursor recover","short":null,"long":"--path","description":"Path to recover context for (default: current directory)","entry_type":"flag"},{"command":"f ai cursor recover","short":null,"long":"--exact-cwd","description":"Restrict lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai cursor recover","short":null,"long":"--limit","description":"Maximum number of candidate sessions to return","entry_type":"flag"},{"command":"f ai cursor recover","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai cursor recover","short":null,"long":"--summary-only","description":"Emit only the compact recovery summary for prompt injection","entry_type":"flag"},{"command":"f ai claude","short":null,"long":null,"description":"Claude Code: continue last session or start new one","entry_type":"subcommand"},{"command":"f ai claude list","short":null,"long":null,"description":"List sessions for this provider","entry_type":"subcommand"},{"command":"f ai claude latest-id","short":null,"long":null,"description":"Print the most recent session ID for this provider","entry_type":"subcommand"},{"command":"f ai claude latest-id","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude sessions","short":null,"long":null,"description":"List provider sessions with IDs","entry_type":"subcommand"},{"command":"f ai claude sessions","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude sessions","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude continue","short":null,"long":null,"description":"Continue the most recent session for this provider","entry_type":"subcommand"},{"command":"f ai claude continue","short":null,"long":"--path","description":"Project path to continue from instead of the current directory","entry_type":"flag"},{"command":"f ai claude new","short":null,"long":null,"description":"Start a new session (ignores existing sessions)","entry_type":"subcommand"},{"command":"f ai claude resume","short":null,"long":null,"description":"Resume a session","entry_type":"subcommand"},{"command":"f ai claude resume","short":null,"long":"--path","description":"Project path to resume from instead of the current directory","entry_type":"flag"},{"command":"f ai claude connect","short":null,"long":null,"description":"Connect to an existing Codex session selected by natural-language query","entry_type":"subcommand"},{"command":"f ai claude connect","short":null,"long":"--path","description":"Project path or repo root to search instead of the configured Codex home-session path","entry_type":"flag"},{"command":"f ai claude connect","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai claude connect","short":null,"long":"--json","description":"Emit machine-readable JSON for the selected session instead of resuming it","entry_type":"flag"},{"command":"f ai claude open","short":null,"long":null,"description":"Open a Codex session with fast repo-scoped recovery and reference unrolling","entry_type":"subcommand"},{"command":"f ai claude open","short":null,"long":"--path","description":"Project path to open from instead of the current directory","entry_type":"flag"},{"command":"f ai claude open","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai claude resolve","short":null,"long":null,"description":"Resolve how `f codex open` would interpret a query","entry_type":"subcommand"},{"command":"f ai claude resolve","short":null,"long":"--path","description":"Project path to resolve from instead of the current directory","entry_type":"flag"},{"command":"f ai claude resolve","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai claude resolve","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude doctor","short":null,"long":null,"description":"Print effective Codex control-plane settings for this path","entry_type":"subcommand"},{"command":"f ai claude doctor","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude doctor","short":null,"long":"--assert-runtime","description":"Exit non-zero unless wrapper transport and runtime skills are active","entry_type":"flag"},{"command":"f ai claude doctor","short":null,"long":"--assert-schedule","description":"Exit non-zero unless the scheduled scorecard refresher is installed and loaded","entry_type":"flag"},{"command":"f ai claude doctor","short":null,"long":"--assert-learning","description":"Exit non-zero unless Flow has grounded learning data for this target","entry_type":"flag"},{"command":"f ai claude doctor","short":null,"long":"--assert-autonomous","description":"Exit non-zero unless runtime, schedule, and grounded learning are all active","entry_type":"flag"},{"command":"f ai claude doctor","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude eval","short":null,"long":null,"description":"Evaluate how well Flow-guided Codex usage is working for this repo/path","entry_type":"subcommand"},{"command":"f ai claude eval","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude eval","short":null,"long":"--limit","description":"Maximum number of recent logged events/outcomes to inspect","entry_type":"flag"},{"command":"f ai claude eval","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":null,"description":"Enable the global Codex wrapper/runtime path so Flow features are actually used","entry_type":"subcommand"},{"command":"f ai claude enable-global","short":null,"long":"--dry-run","description":"Show the resulting global config and actions without writing anything","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--install-launchd","description":"Also install the macOS launchd scorecard refresher","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--start-daemon","description":"Start codexd immediately after enabling the global config","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--sync-skills","description":"Sync any discovered external skill sources after enabling the config","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--full","description":"Shortcut for --install-launchd --start-daemon --sync-skills","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--minutes","description":"Launchd cadence in minutes (used with --install-launchd/--full)","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--limit","description":"Max logged events to scan per launchd run","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--max-targets","description":"Max repos to rebuild per launchd run","entry_type":"flag"},{"command":"f ai claude enable-global","short":null,"long":"--within-hours","description":"Recent-history window for launchd cron selection","entry_type":"flag"},{"command":"f ai claude daemon","short":null,"long":null,"description":"Manage the Flow codexd query daemon","entry_type":"subcommand"},{"command":"f ai claude daemon start","short":null,"long":null,"description":"Start codexd under Flow supervision","entry_type":"subcommand"},{"command":"f ai claude daemon stop","short":null,"long":null,"description":"Stop codexd","entry_type":"subcommand"},{"command":"f ai claude daemon restart","short":null,"long":null,"description":"Restart codexd","entry_type":"subcommand"},{"command":"f ai claude daemon status","short":null,"long":null,"description":"Show codexd status","entry_type":"subcommand"},{"command":"f ai claude memory","short":null,"long":null,"description":"Inspect or sync the Jazz2-backed Codex memory mirror","entry_type":"subcommand"},{"command":"f ai claude memory status","short":null,"long":null,"description":"Show memory mirror status and counts","entry_type":"subcommand"},{"command":"f ai claude memory status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude memory sync","short":null,"long":null,"description":"Sync recent Codex skill-eval logs into the Jazz2-backed memory mirror","entry_type":"subcommand"},{"command":"f ai claude memory sync","short":null,"long":"--limit","description":"Maximum number of recent events and outcomes to ingest","entry_type":"flag"},{"command":"f ai claude memory sync","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude memory query","short":null,"long":null,"description":"Query compact repo/code memory facts for a path","entry_type":"subcommand"},{"command":"f ai claude memory query","short":null,"long":"--path","description":"Project path or repo root to query","entry_type":"flag"},{"command":"f ai claude memory query","short":null,"long":"--limit","description":"Maximum number of fact hits to include","entry_type":"flag"},{"command":"f ai claude memory query","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude memory recent","short":null,"long":null,"description":"Show recent memory rows, optionally scoped to a repo/path","entry_type":"subcommand"},{"command":"f ai claude memory recent","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude memory recent","short":null,"long":"--limit","description":"Maximum number of rows to print","entry_type":"flag"},{"command":"f ai claude memory recent","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude telemetry","short":null,"long":null,"description":"Export redacted Codex workflow telemetry to configured Maple endpoints","entry_type":"subcommand"},{"command":"f ai claude telemetry status","short":null,"long":null,"description":"Show Codex telemetry export config and current forwarder state","entry_type":"subcommand"},{"command":"f ai claude telemetry status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude telemetry flush","short":null,"long":null,"description":"Flush recently logged Codex telemetry to configured Maple endpoints once","entry_type":"subcommand"},{"command":"f ai claude telemetry flush","short":null,"long":"--limit","description":"Maximum number of unseen events/outcomes to export in one pass","entry_type":"flag"},{"command":"f ai claude telemetry flush","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude trace","short":null,"long":null,"description":"Inspect Flow-managed Codex traces for the current or a specific session","entry_type":"subcommand"},{"command":"f ai claude trace status","short":null,"long":null,"description":"Show Maple trace read status and configured credentials","entry_type":"subcommand"},{"command":"f ai claude trace status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude trace current-session","short":null,"long":null,"description":"Inspect the trace associated with the active Flow-managed Codex session","entry_type":"subcommand"},{"command":"f ai claude trace current-session","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f ai claude trace current-session","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude trace inspect","short":null,"long":null,"description":"Inspect a specific trace id","entry_type":"subcommand"},{"command":"f ai claude trace inspect","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f ai claude trace inspect","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude skill-eval","short":null,"long":null,"description":"Build and inspect local Codex skill scorecards from Flow history","entry_type":"subcommand"},{"command":"f ai claude skill-eval run","short":null,"long":null,"description":"Rebuild the local scorecard for this repo/path from recent Flow Codex history","entry_type":"subcommand"},{"command":"f ai claude skill-eval run","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude skill-eval run","short":null,"long":"--limit","description":"Maximum number of recent events to use when rebuilding","entry_type":"flag"},{"command":"f ai claude skill-eval run","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude skill-eval show","short":null,"long":null,"description":"Show the current scorecard for this repo/path","entry_type":"subcommand"},{"command":"f ai claude skill-eval show","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude skill-eval show","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude skill-eval events","short":null,"long":null,"description":"Show recent logged skill-eval events","entry_type":"subcommand"},{"command":"f ai claude skill-eval events","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude skill-eval events","short":null,"long":"--limit","description":"Maximum number of events to print","entry_type":"flag"},{"command":"f ai claude skill-eval events","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude skill-eval cron","short":null,"long":null,"description":"Refresh scorecards for the most recent repos seen in Flow Codex history","entry_type":"subcommand"},{"command":"f ai claude skill-eval cron","short":null,"long":"--limit","description":"Maximum number of logged events to scan for target repos","entry_type":"flag"},{"command":"f ai claude skill-eval cron","short":null,"long":"--max-targets","description":"Maximum number of repo targets to rebuild in one pass","entry_type":"flag"},{"command":"f ai claude skill-eval cron","short":null,"long":"--within-hours","description":"Only consider repos seen within this many recent hours","entry_type":"flag"},{"command":"f ai claude skill-eval cron","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude skill-source","short":null,"long":null,"description":"Discover and sync external Codex skill sources","entry_type":"subcommand"},{"command":"f ai claude skill-source list","short":null,"long":null,"description":"List discovered external skills available for Codex runtime injection","entry_type":"subcommand"},{"command":"f ai claude skill-source list","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude skill-source list","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude skill-source sync","short":null,"long":null,"description":"Copy discovered external skills into ~/.codex/skills for persistent use","entry_type":"subcommand"},{"command":"f ai claude skill-source sync","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai claude skill-source sync","short":null,"long":"--skill","description":"Restrict sync to the named discovered skills","entry_type":"flag"},{"command":"f ai claude skill-source sync","short":null,"long":"--force","description":"Overwrite an existing ~/.codex/skills/<name> directory","entry_type":"flag"},{"command":"f ai claude runtime","short":null,"long":null,"description":"Inspect or manage Flow-managed Codex runtime helpers","entry_type":"subcommand"},{"command":"f ai claude runtime show","short":null,"long":null,"description":"Show recent Flow-managed Codex runtime skill activations","entry_type":"subcommand"},{"command":"f ai claude runtime clear","short":null,"long":null,"description":"Remove Flow-managed runtime skill state and stale symlinks","entry_type":"subcommand"},{"command":"f ai claude runtime write-plan","short":null,"long":null,"description":"Write a markdown plan to ~/plan and print the final path","entry_type":"subcommand"},{"command":"f ai claude runtime write-plan","short":null,"long":"--title","description":"Human-readable title used to derive the filename","entry_type":"flag"},{"command":"f ai claude runtime write-plan","short":null,"long":"--stem","description":"Explicit filename stem to use instead of deriving from the title","entry_type":"flag"},{"command":"f ai claude runtime write-plan","short":null,"long":"--dir","description":"Destination directory (defaults to ~/plan)","entry_type":"flag"},{"command":"f ai claude runtime write-plan","short":null,"long":"--source-session","description":"Codex session id to append as a footer (defaults to $CODEX_THREAD_ID)","entry_type":"flag"},{"command":"f ai claude find","short":null,"long":null,"description":"Search Codex sessions by prompt text and resume the best match","entry_type":"subcommand"},{"command":"f ai claude find","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f ai claude find","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai claude findAndCopy","short":null,"long":null,"description":"Search Codex sessions by prompt text and copy the best match to clipboard","entry_type":"subcommand"},{"command":"f ai claude findAndCopy","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f ai claude findAndCopy","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai claude copy","short":null,"long":null,"description":"Copy session history to clipboard","entry_type":"subcommand"},{"command":"f ai claude context","short":null,"long":null,"description":"Copy last prompt and response to clipboard (for context passing). Usage: f ai claude context [session] [path] [count]","entry_type":"subcommand"},{"command":"f ai claude show","short":null,"long":null,"description":"Print a cleaned session excerpt to stdout","entry_type":"subcommand"},{"command":"f ai claude show","short":null,"long":"--path","description":"Path to project directory (default: current directory)","entry_type":"flag"},{"command":"f ai claude show","short":null,"long":"--count","description":"Number of exchanges to include (default: 12)","entry_type":"flag"},{"command":"f ai claude show","short":null,"long":"--full","description":"Print the full cleaned transcript instead of just the trailing exchanges","entry_type":"flag"},{"command":"f ai claude recover","short":null,"long":null,"description":"Recover recent Codex session context for a repo or subpath","entry_type":"subcommand"},{"command":"f ai claude recover","short":null,"long":"--path","description":"Path to recover context for (default: current directory)","entry_type":"flag"},{"command":"f ai claude recover","short":null,"long":"--exact-cwd","description":"Restrict lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai claude recover","short":null,"long":"--limit","description":"Maximum number of candidate sessions to return","entry_type":"flag"},{"command":"f ai claude recover","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai claude recover","short":null,"long":"--summary-only","description":"Emit only the compact recovery summary for prompt injection","entry_type":"flag"},{"command":"f ai codex","short":null,"long":null,"description":"Codex: continue last session or start new one","entry_type":"subcommand"},{"command":"f ai codex list","short":null,"long":null,"description":"List sessions for this provider","entry_type":"subcommand"},{"command":"f ai codex latest-id","short":null,"long":null,"description":"Print the most recent session ID for this provider","entry_type":"subcommand"},{"command":"f ai codex latest-id","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex sessions","short":null,"long":null,"description":"List provider sessions with IDs","entry_type":"subcommand"},{"command":"f ai codex sessions","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex sessions","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex continue","short":null,"long":null,"description":"Continue the most recent session for this provider","entry_type":"subcommand"},{"command":"f ai codex continue","short":null,"long":"--path","description":"Project path to continue from instead of the current directory","entry_type":"flag"},{"command":"f ai codex new","short":null,"long":null,"description":"Start a new session (ignores existing sessions)","entry_type":"subcommand"},{"command":"f ai codex resume","short":null,"long":null,"description":"Resume a session","entry_type":"subcommand"},{"command":"f ai codex resume","short":null,"long":"--path","description":"Project path to resume from instead of the current directory","entry_type":"flag"},{"command":"f ai codex connect","short":null,"long":null,"description":"Connect to an existing Codex session selected by natural-language query","entry_type":"subcommand"},{"command":"f ai codex connect","short":null,"long":"--path","description":"Project path or repo root to search instead of the configured Codex home-session path","entry_type":"flag"},{"command":"f ai codex connect","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai codex connect","short":null,"long":"--json","description":"Emit machine-readable JSON for the selected session instead of resuming it","entry_type":"flag"},{"command":"f ai codex open","short":null,"long":null,"description":"Open a Codex session with fast repo-scoped recovery and reference unrolling","entry_type":"subcommand"},{"command":"f ai codex open","short":null,"long":"--path","description":"Project path to open from instead of the current directory","entry_type":"flag"},{"command":"f ai codex open","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai codex resolve","short":null,"long":null,"description":"Resolve how `f codex open` would interpret a query","entry_type":"subcommand"},{"command":"f ai codex resolve","short":null,"long":"--path","description":"Project path to resolve from instead of the current directory","entry_type":"flag"},{"command":"f ai codex resolve","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai codex resolve","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex doctor","short":null,"long":null,"description":"Print effective Codex control-plane settings for this path","entry_type":"subcommand"},{"command":"f ai codex doctor","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex doctor","short":null,"long":"--assert-runtime","description":"Exit non-zero unless wrapper transport and runtime skills are active","entry_type":"flag"},{"command":"f ai codex doctor","short":null,"long":"--assert-schedule","description":"Exit non-zero unless the scheduled scorecard refresher is installed and loaded","entry_type":"flag"},{"command":"f ai codex doctor","short":null,"long":"--assert-learning","description":"Exit non-zero unless Flow has grounded learning data for this target","entry_type":"flag"},{"command":"f ai codex doctor","short":null,"long":"--assert-autonomous","description":"Exit non-zero unless runtime, schedule, and grounded learning are all active","entry_type":"flag"},{"command":"f ai codex doctor","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex eval","short":null,"long":null,"description":"Evaluate how well Flow-guided Codex usage is working for this repo/path","entry_type":"subcommand"},{"command":"f ai codex eval","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex eval","short":null,"long":"--limit","description":"Maximum number of recent logged events/outcomes to inspect","entry_type":"flag"},{"command":"f ai codex eval","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":null,"description":"Enable the global Codex wrapper/runtime path so Flow features are actually used","entry_type":"subcommand"},{"command":"f ai codex enable-global","short":null,"long":"--dry-run","description":"Show the resulting global config and actions without writing anything","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--install-launchd","description":"Also install the macOS launchd scorecard refresher","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--start-daemon","description":"Start codexd immediately after enabling the global config","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--sync-skills","description":"Sync any discovered external skill sources after enabling the config","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--full","description":"Shortcut for --install-launchd --start-daemon --sync-skills","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--minutes","description":"Launchd cadence in minutes (used with --install-launchd/--full)","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--limit","description":"Max logged events to scan per launchd run","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--max-targets","description":"Max repos to rebuild per launchd run","entry_type":"flag"},{"command":"f ai codex enable-global","short":null,"long":"--within-hours","description":"Recent-history window for launchd cron selection","entry_type":"flag"},{"command":"f ai codex daemon","short":null,"long":null,"description":"Manage the Flow codexd query daemon","entry_type":"subcommand"},{"command":"f ai codex daemon start","short":null,"long":null,"description":"Start codexd under Flow supervision","entry_type":"subcommand"},{"command":"f ai codex daemon stop","short":null,"long":null,"description":"Stop codexd","entry_type":"subcommand"},{"command":"f ai codex daemon restart","short":null,"long":null,"description":"Restart codexd","entry_type":"subcommand"},{"command":"f ai codex daemon status","short":null,"long":null,"description":"Show codexd status","entry_type":"subcommand"},{"command":"f ai codex memory","short":null,"long":null,"description":"Inspect or sync the Jazz2-backed Codex memory mirror","entry_type":"subcommand"},{"command":"f ai codex memory status","short":null,"long":null,"description":"Show memory mirror status and counts","entry_type":"subcommand"},{"command":"f ai codex memory status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex memory sync","short":null,"long":null,"description":"Sync recent Codex skill-eval logs into the Jazz2-backed memory mirror","entry_type":"subcommand"},{"command":"f ai codex memory sync","short":null,"long":"--limit","description":"Maximum number of recent events and outcomes to ingest","entry_type":"flag"},{"command":"f ai codex memory sync","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex memory query","short":null,"long":null,"description":"Query compact repo/code memory facts for a path","entry_type":"subcommand"},{"command":"f ai codex memory query","short":null,"long":"--path","description":"Project path or repo root to query","entry_type":"flag"},{"command":"f ai codex memory query","short":null,"long":"--limit","description":"Maximum number of fact hits to include","entry_type":"flag"},{"command":"f ai codex memory query","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex memory recent","short":null,"long":null,"description":"Show recent memory rows, optionally scoped to a repo/path","entry_type":"subcommand"},{"command":"f ai codex memory recent","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex memory recent","short":null,"long":"--limit","description":"Maximum number of rows to print","entry_type":"flag"},{"command":"f ai codex memory recent","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex telemetry","short":null,"long":null,"description":"Export redacted Codex workflow telemetry to configured Maple endpoints","entry_type":"subcommand"},{"command":"f ai codex telemetry status","short":null,"long":null,"description":"Show Codex telemetry export config and current forwarder state","entry_type":"subcommand"},{"command":"f ai codex telemetry status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex telemetry flush","short":null,"long":null,"description":"Flush recently logged Codex telemetry to configured Maple endpoints once","entry_type":"subcommand"},{"command":"f ai codex telemetry flush","short":null,"long":"--limit","description":"Maximum number of unseen events/outcomes to export in one pass","entry_type":"flag"},{"command":"f ai codex telemetry flush","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex trace","short":null,"long":null,"description":"Inspect Flow-managed Codex traces for the current or a specific session","entry_type":"subcommand"},{"command":"f ai codex trace status","short":null,"long":null,"description":"Show Maple trace read status and configured credentials","entry_type":"subcommand"},{"command":"f ai codex trace status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex trace current-session","short":null,"long":null,"description":"Inspect the trace associated with the active Flow-managed Codex session","entry_type":"subcommand"},{"command":"f ai codex trace current-session","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f ai codex trace current-session","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex trace inspect","short":null,"long":null,"description":"Inspect a specific trace id","entry_type":"subcommand"},{"command":"f ai codex trace inspect","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f ai codex trace inspect","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex skill-eval","short":null,"long":null,"description":"Build and inspect local Codex skill scorecards from Flow history","entry_type":"subcommand"},{"command":"f ai codex skill-eval run","short":null,"long":null,"description":"Rebuild the local scorecard for this repo/path from recent Flow Codex history","entry_type":"subcommand"},{"command":"f ai codex skill-eval run","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex skill-eval run","short":null,"long":"--limit","description":"Maximum number of recent events to use when rebuilding","entry_type":"flag"},{"command":"f ai codex skill-eval run","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex skill-eval show","short":null,"long":null,"description":"Show the current scorecard for this repo/path","entry_type":"subcommand"},{"command":"f ai codex skill-eval show","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex skill-eval show","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex skill-eval events","short":null,"long":null,"description":"Show recent logged skill-eval events","entry_type":"subcommand"},{"command":"f ai codex skill-eval events","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex skill-eval events","short":null,"long":"--limit","description":"Maximum number of events to print","entry_type":"flag"},{"command":"f ai codex skill-eval events","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex skill-eval cron","short":null,"long":null,"description":"Refresh scorecards for the most recent repos seen in Flow Codex history","entry_type":"subcommand"},{"command":"f ai codex skill-eval cron","short":null,"long":"--limit","description":"Maximum number of logged events to scan for target repos","entry_type":"flag"},{"command":"f ai codex skill-eval cron","short":null,"long":"--max-targets","description":"Maximum number of repo targets to rebuild in one pass","entry_type":"flag"},{"command":"f ai codex skill-eval cron","short":null,"long":"--within-hours","description":"Only consider repos seen within this many recent hours","entry_type":"flag"},{"command":"f ai codex skill-eval cron","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex skill-source","short":null,"long":null,"description":"Discover and sync external Codex skill sources","entry_type":"subcommand"},{"command":"f ai codex skill-source list","short":null,"long":null,"description":"List discovered external skills available for Codex runtime injection","entry_type":"subcommand"},{"command":"f ai codex skill-source list","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex skill-source list","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex skill-source sync","short":null,"long":null,"description":"Copy discovered external skills into ~/.codex/skills for persistent use","entry_type":"subcommand"},{"command":"f ai codex skill-source sync","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f ai codex skill-source sync","short":null,"long":"--skill","description":"Restrict sync to the named discovered skills","entry_type":"flag"},{"command":"f ai codex skill-source sync","short":null,"long":"--force","description":"Overwrite an existing ~/.codex/skills/<name> directory","entry_type":"flag"},{"command":"f ai codex runtime","short":null,"long":null,"description":"Inspect or manage Flow-managed Codex runtime helpers","entry_type":"subcommand"},{"command":"f ai codex runtime show","short":null,"long":null,"description":"Show recent Flow-managed Codex runtime skill activations","entry_type":"subcommand"},{"command":"f ai codex runtime clear","short":null,"long":null,"description":"Remove Flow-managed runtime skill state and stale symlinks","entry_type":"subcommand"},{"command":"f ai codex runtime write-plan","short":null,"long":null,"description":"Write a markdown plan to ~/plan and print the final path","entry_type":"subcommand"},{"command":"f ai codex runtime write-plan","short":null,"long":"--title","description":"Human-readable title used to derive the filename","entry_type":"flag"},{"command":"f ai codex runtime write-plan","short":null,"long":"--stem","description":"Explicit filename stem to use instead of deriving from the title","entry_type":"flag"},{"command":"f ai codex runtime write-plan","short":null,"long":"--dir","description":"Destination directory (defaults to ~/plan)","entry_type":"flag"},{"command":"f ai codex runtime write-plan","short":null,"long":"--source-session","description":"Codex session id to append as a footer (defaults to $CODEX_THREAD_ID)","entry_type":"flag"},{"command":"f ai codex find","short":null,"long":null,"description":"Search Codex sessions by prompt text and resume the best match","entry_type":"subcommand"},{"command":"f ai codex find","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f ai codex find","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai codex findAndCopy","short":null,"long":null,"description":"Search Codex sessions by prompt text and copy the best match to clipboard","entry_type":"subcommand"},{"command":"f ai codex findAndCopy","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f ai codex findAndCopy","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai codex copy","short":null,"long":null,"description":"Copy session history to clipboard","entry_type":"subcommand"},{"command":"f ai codex context","short":null,"long":null,"description":"Copy last prompt and response to clipboard (for context passing). Usage: f ai claude context [session] [path] [count]","entry_type":"subcommand"},{"command":"f ai codex show","short":null,"long":null,"description":"Print a cleaned session excerpt to stdout","entry_type":"subcommand"},{"command":"f ai codex show","short":null,"long":"--path","description":"Path to project directory (default: current directory)","entry_type":"flag"},{"command":"f ai codex show","short":null,"long":"--count","description":"Number of exchanges to include (default: 12)","entry_type":"flag"},{"command":"f ai codex show","short":null,"long":"--full","description":"Print the full cleaned transcript instead of just the trailing exchanges","entry_type":"flag"},{"command":"f ai codex recover","short":null,"long":null,"description":"Recover recent Codex session context for a repo or subpath","entry_type":"subcommand"},{"command":"f ai codex recover","short":null,"long":"--path","description":"Path to recover context for (default: current directory)","entry_type":"flag"},{"command":"f ai codex recover","short":null,"long":"--exact-cwd","description":"Restrict lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f ai codex recover","short":null,"long":"--limit","description":"Maximum number of candidate sessions to return","entry_type":"flag"},{"command":"f ai codex recover","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f ai codex recover","short":null,"long":"--summary-only","description":"Emit only the compact recovery summary for prompt injection","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":null,"description":"Run a prompt through Everruns and bridge client-side tool calls to seqd","entry_type":"subcommand"},{"command":"f ai everruns","short":null,"long":"--session-id","description":"Reuse an existing Everruns session ID","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--agent-id","description":"Agent ID to use when creating a new session","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--harness-id","description":"Harness ID to use when creating a new session","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--model-id","description":"Model ID override when creating a new session","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--base-url","description":"Everruns API base URL (default: http://127.0.0.1:9300/api)","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--api-key","description":"Everruns API key (Bearer token). Prefer env var when possible","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--poll-ms","description":"Poll interval for /events while waiting for completion","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--wait-timeout-secs","description":"Max seconds to wait for output/tool cycles before timing out","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--seq-socket","description":"Path to seqd Unix socket (default: $SEQ_SOCKET_PATH, then /tmp/seqd.sock)","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--seq-timeout-ms","description":"Read/write timeout for seqd RPC calls in milliseconds","entry_type":"flag"},{"command":"f ai everruns","short":null,"long":"--no-seq-tools","description":"Do not inject seq client-side tool definitions when creating a new session","entry_type":"flag"},{"command":"f ai resume","short":null,"long":null,"description":"Resume an AI session by name or ID","entry_type":"subcommand"},{"command":"f ai resume","short":null,"long":"--path","description":"Project path to resume from instead of the current directory","entry_type":"flag"},{"command":"f ai save","short":null,"long":null,"description":"Save/bookmark the current or most recent session with a name","entry_type":"subcommand"},{"command":"f ai save","short":null,"long":"--id","description":"Session ID to save (defaults to most recent)","entry_type":"flag"},{"command":"f ai notes","short":null,"long":null,"description":"Open or create notes for a session","entry_type":"subcommand"},{"command":"f ai remove","short":null,"long":null,"description":"Remove a saved session from tracking (doesn't delete the actual session)","entry_type":"subcommand"},{"command":"f ai init","short":null,"long":null,"description":"Initialize .ai folder structure in current project","entry_type":"subcommand"},{"command":"f ai import","short":null,"long":null,"description":"Import all existing sessions for this project","entry_type":"subcommand"},{"command":"f ai copy","short":null,"long":null,"description":"Copy session history to clipboard (fuzzy search to select)","entry_type":"subcommand"},{"command":"f ai copy-claude","short":null,"long":null,"description":"Copy last Claude session to clipboard. Optionally search for a session containing text","entry_type":"subcommand"},{"command":"f ai copy-codex","short":null,"long":null,"description":"Copy last Codex session to clipboard. Optionally search for a session containing text","entry_type":"subcommand"},{"command":"f ai context","short":null,"long":null,"description":"Copy last prompt and response from a session to clipboard (for context passing). Usage: f ai context [session] [path] [count]","entry_type":"subcommand"},{"command":"f codex","short":null,"long":null,"description":"Start or continue Codex session.","entry_type":"subcommand"},{"command":"f codex list","short":null,"long":null,"description":"List sessions for this provider","entry_type":"subcommand"},{"command":"f codex latest-id","short":null,"long":null,"description":"Print the most recent session ID for this provider","entry_type":"subcommand"},{"command":"f codex latest-id","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex sessions","short":null,"long":null,"description":"List provider sessions with IDs","entry_type":"subcommand"},{"command":"f codex sessions","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex sessions","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex continue","short":null,"long":null,"description":"Continue the most recent session for this provider","entry_type":"subcommand"},{"command":"f codex continue","short":null,"long":"--path","description":"Project path to continue from instead of the current directory","entry_type":"flag"},{"command":"f codex new","short":null,"long":null,"description":"Start a new session (ignores existing sessions)","entry_type":"subcommand"},{"command":"f codex resume","short":null,"long":null,"description":"Resume a session","entry_type":"subcommand"},{"command":"f codex resume","short":null,"long":"--path","description":"Project path to resume from instead of the current directory","entry_type":"flag"},{"command":"f codex connect","short":null,"long":null,"description":"Connect to an existing Codex session selected by natural-language query","entry_type":"subcommand"},{"command":"f codex connect","short":null,"long":"--path","description":"Project path or repo root to search instead of the configured Codex home-session path","entry_type":"flag"},{"command":"f codex connect","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f codex connect","short":null,"long":"--json","description":"Emit machine-readable JSON for the selected session instead of resuming it","entry_type":"flag"},{"command":"f codex open","short":null,"long":null,"description":"Open a Codex session with fast repo-scoped recovery and reference unrolling","entry_type":"subcommand"},{"command":"f codex open","short":null,"long":"--path","description":"Project path to open from instead of the current directory","entry_type":"flag"},{"command":"f codex open","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f codex resolve","short":null,"long":null,"description":"Resolve how `f codex open` would interpret a query","entry_type":"subcommand"},{"command":"f codex resolve","short":null,"long":"--path","description":"Project path to resolve from instead of the current directory","entry_type":"flag"},{"command":"f codex resolve","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f codex resolve","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex doctor","short":null,"long":null,"description":"Print effective Codex control-plane settings for this path","entry_type":"subcommand"},{"command":"f codex doctor","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex doctor","short":null,"long":"--assert-runtime","description":"Exit non-zero unless wrapper transport and runtime skills are active","entry_type":"flag"},{"command":"f codex doctor","short":null,"long":"--assert-schedule","description":"Exit non-zero unless the scheduled scorecard refresher is installed and loaded","entry_type":"flag"},{"command":"f codex doctor","short":null,"long":"--assert-learning","description":"Exit non-zero unless Flow has grounded learning data for this target","entry_type":"flag"},{"command":"f codex doctor","short":null,"long":"--assert-autonomous","description":"Exit non-zero unless runtime, schedule, and grounded learning are all active","entry_type":"flag"},{"command":"f codex doctor","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex eval","short":null,"long":null,"description":"Evaluate how well Flow-guided Codex usage is working for this repo/path","entry_type":"subcommand"},{"command":"f codex eval","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex eval","short":null,"long":"--limit","description":"Maximum number of recent logged events/outcomes to inspect","entry_type":"flag"},{"command":"f codex eval","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":null,"description":"Enable the global Codex wrapper/runtime path so Flow features are actually used","entry_type":"subcommand"},{"command":"f codex enable-global","short":null,"long":"--dry-run","description":"Show the resulting global config and actions without writing anything","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--install-launchd","description":"Also install the macOS launchd scorecard refresher","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--start-daemon","description":"Start codexd immediately after enabling the global config","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--sync-skills","description":"Sync any discovered external skill sources after enabling the config","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--full","description":"Shortcut for --install-launchd --start-daemon --sync-skills","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--minutes","description":"Launchd cadence in minutes (used with --install-launchd/--full)","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--limit","description":"Max logged events to scan per launchd run","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--max-targets","description":"Max repos to rebuild per launchd run","entry_type":"flag"},{"command":"f codex enable-global","short":null,"long":"--within-hours","description":"Recent-history window for launchd cron selection","entry_type":"flag"},{"command":"f codex daemon","short":null,"long":null,"description":"Manage the Flow codexd query daemon","entry_type":"subcommand"},{"command":"f codex daemon start","short":null,"long":null,"description":"Start codexd under Flow supervision","entry_type":"subcommand"},{"command":"f codex daemon stop","short":null,"long":null,"description":"Stop codexd","entry_type":"subcommand"},{"command":"f codex daemon restart","short":null,"long":null,"description":"Restart codexd","entry_type":"subcommand"},{"command":"f codex daemon status","short":null,"long":null,"description":"Show codexd status","entry_type":"subcommand"},{"command":"f codex memory","short":null,"long":null,"description":"Inspect or sync the Jazz2-backed Codex memory mirror","entry_type":"subcommand"},{"command":"f codex memory status","short":null,"long":null,"description":"Show memory mirror status and counts","entry_type":"subcommand"},{"command":"f codex memory status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex memory sync","short":null,"long":null,"description":"Sync recent Codex skill-eval logs into the Jazz2-backed memory mirror","entry_type":"subcommand"},{"command":"f codex memory sync","short":null,"long":"--limit","description":"Maximum number of recent events and outcomes to ingest","entry_type":"flag"},{"command":"f codex memory sync","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex memory query","short":null,"long":null,"description":"Query compact repo/code memory facts for a path","entry_type":"subcommand"},{"command":"f codex memory query","short":null,"long":"--path","description":"Project path or repo root to query","entry_type":"flag"},{"command":"f codex memory query","short":null,"long":"--limit","description":"Maximum number of fact hits to include","entry_type":"flag"},{"command":"f codex memory query","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex memory recent","short":null,"long":null,"description":"Show recent memory rows, optionally scoped to a repo/path","entry_type":"subcommand"},{"command":"f codex memory recent","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex memory recent","short":null,"long":"--limit","description":"Maximum number of rows to print","entry_type":"flag"},{"command":"f codex memory recent","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex telemetry","short":null,"long":null,"description":"Export redacted Codex workflow telemetry to configured Maple endpoints","entry_type":"subcommand"},{"command":"f codex telemetry status","short":null,"long":null,"description":"Show Codex telemetry export config and current forwarder state","entry_type":"subcommand"},{"command":"f codex telemetry status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex telemetry flush","short":null,"long":null,"description":"Flush recently logged Codex telemetry to configured Maple endpoints once","entry_type":"subcommand"},{"command":"f codex telemetry flush","short":null,"long":"--limit","description":"Maximum number of unseen events/outcomes to export in one pass","entry_type":"flag"},{"command":"f codex telemetry flush","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex trace","short":null,"long":null,"description":"Inspect Flow-managed Codex traces for the current or a specific session","entry_type":"subcommand"},{"command":"f codex trace status","short":null,"long":null,"description":"Show Maple trace read status and configured credentials","entry_type":"subcommand"},{"command":"f codex trace status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex trace current-session","short":null,"long":null,"description":"Inspect the trace associated with the active Flow-managed Codex session","entry_type":"subcommand"},{"command":"f codex trace current-session","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f codex trace current-session","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex trace inspect","short":null,"long":null,"description":"Inspect a specific trace id","entry_type":"subcommand"},{"command":"f codex trace inspect","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f codex trace inspect","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex skill-eval","short":null,"long":null,"description":"Build and inspect local Codex skill scorecards from Flow history","entry_type":"subcommand"},{"command":"f codex skill-eval run","short":null,"long":null,"description":"Rebuild the local scorecard for this repo/path from recent Flow Codex history","entry_type":"subcommand"},{"command":"f codex skill-eval run","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex skill-eval run","short":null,"long":"--limit","description":"Maximum number of recent events to use when rebuilding","entry_type":"flag"},{"command":"f codex skill-eval run","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex skill-eval show","short":null,"long":null,"description":"Show the current scorecard for this repo/path","entry_type":"subcommand"},{"command":"f codex skill-eval show","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex skill-eval show","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex skill-eval events","short":null,"long":null,"description":"Show recent logged skill-eval events","entry_type":"subcommand"},{"command":"f codex skill-eval events","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex skill-eval events","short":null,"long":"--limit","description":"Maximum number of events to print","entry_type":"flag"},{"command":"f codex skill-eval events","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex skill-eval cron","short":null,"long":null,"description":"Refresh scorecards for the most recent repos seen in Flow Codex history","entry_type":"subcommand"},{"command":"f codex skill-eval cron","short":null,"long":"--limit","description":"Maximum number of logged events to scan for target repos","entry_type":"flag"},{"command":"f codex skill-eval cron","short":null,"long":"--max-targets","description":"Maximum number of repo targets to rebuild in one pass","entry_type":"flag"},{"command":"f codex skill-eval cron","short":null,"long":"--within-hours","description":"Only consider repos seen within this many recent hours","entry_type":"flag"},{"command":"f codex skill-eval cron","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex skill-source","short":null,"long":null,"description":"Discover and sync external Codex skill sources","entry_type":"subcommand"},{"command":"f codex skill-source list","short":null,"long":null,"description":"List discovered external skills available for Codex runtime injection","entry_type":"subcommand"},{"command":"f codex skill-source list","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex skill-source list","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex skill-source sync","short":null,"long":null,"description":"Copy discovered external skills into ~/.codex/skills for persistent use","entry_type":"subcommand"},{"command":"f codex skill-source sync","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f codex skill-source sync","short":null,"long":"--skill","description":"Restrict sync to the named discovered skills","entry_type":"flag"},{"command":"f codex skill-source sync","short":null,"long":"--force","description":"Overwrite an existing ~/.codex/skills/<name> directory","entry_type":"flag"},{"command":"f codex runtime","short":null,"long":null,"description":"Inspect or manage Flow-managed Codex runtime helpers","entry_type":"subcommand"},{"command":"f codex runtime show","short":null,"long":null,"description":"Show recent Flow-managed Codex runtime skill activations","entry_type":"subcommand"},{"command":"f codex runtime clear","short":null,"long":null,"description":"Remove Flow-managed runtime skill state and stale symlinks","entry_type":"subcommand"},{"command":"f codex runtime write-plan","short":null,"long":null,"description":"Write a markdown plan to ~/plan and print the final path","entry_type":"subcommand"},{"command":"f codex runtime write-plan","short":null,"long":"--title","description":"Human-readable title used to derive the filename","entry_type":"flag"},{"command":"f codex runtime write-plan","short":null,"long":"--stem","description":"Explicit filename stem to use instead of deriving from the title","entry_type":"flag"},{"command":"f codex runtime write-plan","short":null,"long":"--dir","description":"Destination directory (defaults to ~/plan)","entry_type":"flag"},{"command":"f codex runtime write-plan","short":null,"long":"--source-session","description":"Codex session id to append as a footer (defaults to $CODEX_THREAD_ID)","entry_type":"flag"},{"command":"f codex find","short":null,"long":null,"description":"Search Codex sessions by prompt text and resume the best match","entry_type":"subcommand"},{"command":"f codex find","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f codex find","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f codex findAndCopy","short":null,"long":null,"description":"Search Codex sessions by prompt text and copy the best match to clipboard","entry_type":"subcommand"},{"command":"f codex findAndCopy","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f codex findAndCopy","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f codex copy","short":null,"long":null,"description":"Copy session history to clipboard","entry_type":"subcommand"},{"command":"f codex context","short":null,"long":null,"description":"Copy last prompt and response to clipboard (for context passing). Usage: f ai claude context [session] [path] [count]","entry_type":"subcommand"},{"command":"f codex show","short":null,"long":null,"description":"Print a cleaned session excerpt to stdout","entry_type":"subcommand"},{"command":"f codex show","short":null,"long":"--path","description":"Path to project directory (default: current directory)","entry_type":"flag"},{"command":"f codex show","short":null,"long":"--count","description":"Number of exchanges to include (default: 12)","entry_type":"flag"},{"command":"f codex show","short":null,"long":"--full","description":"Print the full cleaned transcript instead of just the trailing exchanges","entry_type":"flag"},{"command":"f codex recover","short":null,"long":null,"description":"Recover recent Codex session context for a repo or subpath","entry_type":"subcommand"},{"command":"f codex recover","short":null,"long":"--path","description":"Path to recover context for (default: current directory)","entry_type":"flag"},{"command":"f codex recover","short":null,"long":"--exact-cwd","description":"Restrict lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f codex recover","short":null,"long":"--limit","description":"Maximum number of candidate sessions to return","entry_type":"flag"},{"command":"f codex recover","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f codex recover","short":null,"long":"--summary-only","description":"Emit only the compact recovery summary for prompt injection","entry_type":"flag"},{"command":"f cursor","short":null,"long":null,"description":"Read Cursor agent transcripts for this project.","entry_type":"subcommand"},{"command":"f cursor list","short":null,"long":null,"description":"List sessions for this provider","entry_type":"subcommand"},{"command":"f cursor latest-id","short":null,"long":null,"description":"Print the most recent session ID for this provider","entry_type":"subcommand"},{"command":"f cursor latest-id","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor sessions","short":null,"long":null,"description":"List provider sessions with IDs","entry_type":"subcommand"},{"command":"f cursor sessions","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor sessions","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor continue","short":null,"long":null,"description":"Continue the most recent session for this provider","entry_type":"subcommand"},{"command":"f cursor continue","short":null,"long":"--path","description":"Project path to continue from instead of the current directory","entry_type":"flag"},{"command":"f cursor new","short":null,"long":null,"description":"Start a new session (ignores existing sessions)","entry_type":"subcommand"},{"command":"f cursor resume","short":null,"long":null,"description":"Resume a session","entry_type":"subcommand"},{"command":"f cursor resume","short":null,"long":"--path","description":"Project path to resume from instead of the current directory","entry_type":"flag"},{"command":"f cursor connect","short":null,"long":null,"description":"Connect to an existing Codex session selected by natural-language query","entry_type":"subcommand"},{"command":"f cursor connect","short":null,"long":"--path","description":"Project path or repo root to search instead of the configured Codex home-session path","entry_type":"flag"},{"command":"f cursor connect","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f cursor connect","short":null,"long":"--json","description":"Emit machine-readable JSON for the selected session instead of resuming it","entry_type":"flag"},{"command":"f cursor open","short":null,"long":null,"description":"Open a Codex session with fast repo-scoped recovery and reference unrolling","entry_type":"subcommand"},{"command":"f cursor open","short":null,"long":"--path","description":"Project path to open from instead of the current directory","entry_type":"flag"},{"command":"f cursor open","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f cursor resolve","short":null,"long":null,"description":"Resolve how `f codex open` would interpret a query","entry_type":"subcommand"},{"command":"f cursor resolve","short":null,"long":"--path","description":"Project path to resolve from instead of the current directory","entry_type":"flag"},{"command":"f cursor resolve","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f cursor resolve","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor doctor","short":null,"long":null,"description":"Print effective Codex control-plane settings for this path","entry_type":"subcommand"},{"command":"f cursor doctor","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor doctor","short":null,"long":"--assert-runtime","description":"Exit non-zero unless wrapper transport and runtime skills are active","entry_type":"flag"},{"command":"f cursor doctor","short":null,"long":"--assert-schedule","description":"Exit non-zero unless the scheduled scorecard refresher is installed and loaded","entry_type":"flag"},{"command":"f cursor doctor","short":null,"long":"--assert-learning","description":"Exit non-zero unless Flow has grounded learning data for this target","entry_type":"flag"},{"command":"f cursor doctor","short":null,"long":"--assert-autonomous","description":"Exit non-zero unless runtime, schedule, and grounded learning are all active","entry_type":"flag"},{"command":"f cursor doctor","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor eval","short":null,"long":null,"description":"Evaluate how well Flow-guided Codex usage is working for this repo/path","entry_type":"subcommand"},{"command":"f cursor eval","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor eval","short":null,"long":"--limit","description":"Maximum number of recent logged events/outcomes to inspect","entry_type":"flag"},{"command":"f cursor eval","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":null,"description":"Enable the global Codex wrapper/runtime path so Flow features are actually used","entry_type":"subcommand"},{"command":"f cursor enable-global","short":null,"long":"--dry-run","description":"Show the resulting global config and actions without writing anything","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--install-launchd","description":"Also install the macOS launchd scorecard refresher","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--start-daemon","description":"Start codexd immediately after enabling the global config","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--sync-skills","description":"Sync any discovered external skill sources after enabling the config","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--full","description":"Shortcut for --install-launchd --start-daemon --sync-skills","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--minutes","description":"Launchd cadence in minutes (used with --install-launchd/--full)","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--limit","description":"Max logged events to scan per launchd run","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--max-targets","description":"Max repos to rebuild per launchd run","entry_type":"flag"},{"command":"f cursor enable-global","short":null,"long":"--within-hours","description":"Recent-history window for launchd cron selection","entry_type":"flag"},{"command":"f cursor daemon","short":null,"long":null,"description":"Manage the Flow codexd query daemon","entry_type":"subcommand"},{"command":"f cursor daemon start","short":null,"long":null,"description":"Start codexd under Flow supervision","entry_type":"subcommand"},{"command":"f cursor daemon stop","short":null,"long":null,"description":"Stop codexd","entry_type":"subcommand"},{"command":"f cursor daemon restart","short":null,"long":null,"description":"Restart codexd","entry_type":"subcommand"},{"command":"f cursor daemon status","short":null,"long":null,"description":"Show codexd status","entry_type":"subcommand"},{"command":"f cursor memory","short":null,"long":null,"description":"Inspect or sync the Jazz2-backed Codex memory mirror","entry_type":"subcommand"},{"command":"f cursor memory status","short":null,"long":null,"description":"Show memory mirror status and counts","entry_type":"subcommand"},{"command":"f cursor memory status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor memory sync","short":null,"long":null,"description":"Sync recent Codex skill-eval logs into the Jazz2-backed memory mirror","entry_type":"subcommand"},{"command":"f cursor memory sync","short":null,"long":"--limit","description":"Maximum number of recent events and outcomes to ingest","entry_type":"flag"},{"command":"f cursor memory sync","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor memory query","short":null,"long":null,"description":"Query compact repo/code memory facts for a path","entry_type":"subcommand"},{"command":"f cursor memory query","short":null,"long":"--path","description":"Project path or repo root to query","entry_type":"flag"},{"command":"f cursor memory query","short":null,"long":"--limit","description":"Maximum number of fact hits to include","entry_type":"flag"},{"command":"f cursor memory query","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor memory recent","short":null,"long":null,"description":"Show recent memory rows, optionally scoped to a repo/path","entry_type":"subcommand"},{"command":"f cursor memory recent","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor memory recent","short":null,"long":"--limit","description":"Maximum number of rows to print","entry_type":"flag"},{"command":"f cursor memory recent","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor telemetry","short":null,"long":null,"description":"Export redacted Codex workflow telemetry to configured Maple endpoints","entry_type":"subcommand"},{"command":"f cursor telemetry status","short":null,"long":null,"description":"Show Codex telemetry export config and current forwarder state","entry_type":"subcommand"},{"command":"f cursor telemetry status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor telemetry flush","short":null,"long":null,"description":"Flush recently logged Codex telemetry to configured Maple endpoints once","entry_type":"subcommand"},{"command":"f cursor telemetry flush","short":null,"long":"--limit","description":"Maximum number of unseen events/outcomes to export in one pass","entry_type":"flag"},{"command":"f cursor telemetry flush","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor trace","short":null,"long":null,"description":"Inspect Flow-managed Codex traces for the current or a specific session","entry_type":"subcommand"},{"command":"f cursor trace status","short":null,"long":null,"description":"Show Maple trace read status and configured credentials","entry_type":"subcommand"},{"command":"f cursor trace status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor trace current-session","short":null,"long":null,"description":"Inspect the trace associated with the active Flow-managed Codex session","entry_type":"subcommand"},{"command":"f cursor trace current-session","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f cursor trace current-session","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor trace inspect","short":null,"long":null,"description":"Inspect a specific trace id","entry_type":"subcommand"},{"command":"f cursor trace inspect","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f cursor trace inspect","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor skill-eval","short":null,"long":null,"description":"Build and inspect local Codex skill scorecards from Flow history","entry_type":"subcommand"},{"command":"f cursor skill-eval run","short":null,"long":null,"description":"Rebuild the local scorecard for this repo/path from recent Flow Codex history","entry_type":"subcommand"},{"command":"f cursor skill-eval run","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor skill-eval run","short":null,"long":"--limit","description":"Maximum number of recent events to use when rebuilding","entry_type":"flag"},{"command":"f cursor skill-eval run","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor skill-eval show","short":null,"long":null,"description":"Show the current scorecard for this repo/path","entry_type":"subcommand"},{"command":"f cursor skill-eval show","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor skill-eval show","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor skill-eval events","short":null,"long":null,"description":"Show recent logged skill-eval events","entry_type":"subcommand"},{"command":"f cursor skill-eval events","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor skill-eval events","short":null,"long":"--limit","description":"Maximum number of events to print","entry_type":"flag"},{"command":"f cursor skill-eval events","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor skill-eval cron","short":null,"long":null,"description":"Refresh scorecards for the most recent repos seen in Flow Codex history","entry_type":"subcommand"},{"command":"f cursor skill-eval cron","short":null,"long":"--limit","description":"Maximum number of logged events to scan for target repos","entry_type":"flag"},{"command":"f cursor skill-eval cron","short":null,"long":"--max-targets","description":"Maximum number of repo targets to rebuild in one pass","entry_type":"flag"},{"command":"f cursor skill-eval cron","short":null,"long":"--within-hours","description":"Only consider repos seen within this many recent hours","entry_type":"flag"},{"command":"f cursor skill-eval cron","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor skill-source","short":null,"long":null,"description":"Discover and sync external Codex skill sources","entry_type":"subcommand"},{"command":"f cursor skill-source list","short":null,"long":null,"description":"List discovered external skills available for Codex runtime injection","entry_type":"subcommand"},{"command":"f cursor skill-source list","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor skill-source list","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor skill-source sync","short":null,"long":null,"description":"Copy discovered external skills into ~/.codex/skills for persistent use","entry_type":"subcommand"},{"command":"f cursor skill-source sync","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f cursor skill-source sync","short":null,"long":"--skill","description":"Restrict sync to the named discovered skills","entry_type":"flag"},{"command":"f cursor skill-source sync","short":null,"long":"--force","description":"Overwrite an existing ~/.codex/skills/<name> directory","entry_type":"flag"},{"command":"f cursor runtime","short":null,"long":null,"description":"Inspect or manage Flow-managed Codex runtime helpers","entry_type":"subcommand"},{"command":"f cursor runtime show","short":null,"long":null,"description":"Show recent Flow-managed Codex runtime skill activations","entry_type":"subcommand"},{"command":"f cursor runtime clear","short":null,"long":null,"description":"Remove Flow-managed runtime skill state and stale symlinks","entry_type":"subcommand"},{"command":"f cursor runtime write-plan","short":null,"long":null,"description":"Write a markdown plan to ~/plan and print the final path","entry_type":"subcommand"},{"command":"f cursor runtime write-plan","short":null,"long":"--title","description":"Human-readable title used to derive the filename","entry_type":"flag"},{"command":"f cursor runtime write-plan","short":null,"long":"--stem","description":"Explicit filename stem to use instead of deriving from the title","entry_type":"flag"},{"command":"f cursor runtime write-plan","short":null,"long":"--dir","description":"Destination directory (defaults to ~/plan)","entry_type":"flag"},{"command":"f cursor runtime write-plan","short":null,"long":"--source-session","description":"Codex session id to append as a footer (defaults to $CODEX_THREAD_ID)","entry_type":"flag"},{"command":"f cursor find","short":null,"long":null,"description":"Search Codex sessions by prompt text and resume the best match","entry_type":"subcommand"},{"command":"f cursor find","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f cursor find","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f cursor findAndCopy","short":null,"long":null,"description":"Search Codex sessions by prompt text and copy the best match to clipboard","entry_type":"subcommand"},{"command":"f cursor findAndCopy","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f cursor findAndCopy","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f cursor copy","short":null,"long":null,"description":"Copy session history to clipboard","entry_type":"subcommand"},{"command":"f cursor context","short":null,"long":null,"description":"Copy last prompt and response to clipboard (for context passing). Usage: f ai claude context [session] [path] [count]","entry_type":"subcommand"},{"command":"f cursor show","short":null,"long":null,"description":"Print a cleaned session excerpt to stdout","entry_type":"subcommand"},{"command":"f cursor show","short":null,"long":"--path","description":"Path to project directory (default: current directory)","entry_type":"flag"},{"command":"f cursor show","short":null,"long":"--count","description":"Number of exchanges to include (default: 12)","entry_type":"flag"},{"command":"f cursor show","short":null,"long":"--full","description":"Print the full cleaned transcript instead of just the trailing exchanges","entry_type":"flag"},{"command":"f cursor recover","short":null,"long":null,"description":"Recover recent Codex session context for a repo or subpath","entry_type":"subcommand"},{"command":"f cursor recover","short":null,"long":"--path","description":"Path to recover context for (default: current directory)","entry_type":"flag"},{"command":"f cursor recover","short":null,"long":"--exact-cwd","description":"Restrict lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f cursor recover","short":null,"long":"--limit","description":"Maximum number of candidate sessions to return","entry_type":"flag"},{"command":"f cursor recover","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f cursor recover","short":null,"long":"--summary-only","description":"Emit only the compact recovery summary for prompt injection","entry_type":"flag"},{"command":"f claude","short":null,"long":null,"description":"Start or continue Claude session.","entry_type":"subcommand"},{"command":"f claude list","short":null,"long":null,"description":"List sessions for this provider","entry_type":"subcommand"},{"command":"f claude latest-id","short":null,"long":null,"description":"Print the most recent session ID for this provider","entry_type":"subcommand"},{"command":"f claude latest-id","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude sessions","short":null,"long":null,"description":"List provider sessions with IDs","entry_type":"subcommand"},{"command":"f claude sessions","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude sessions","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude continue","short":null,"long":null,"description":"Continue the most recent session for this provider","entry_type":"subcommand"},{"command":"f claude continue","short":null,"long":"--path","description":"Project path to continue from instead of the current directory","entry_type":"flag"},{"command":"f claude new","short":null,"long":null,"description":"Start a new session (ignores existing sessions)","entry_type":"subcommand"},{"command":"f claude resume","short":null,"long":null,"description":"Resume a session","entry_type":"subcommand"},{"command":"f claude resume","short":null,"long":"--path","description":"Project path to resume from instead of the current directory","entry_type":"flag"},{"command":"f claude connect","short":null,"long":null,"description":"Connect to an existing Codex session selected by natural-language query","entry_type":"subcommand"},{"command":"f claude connect","short":null,"long":"--path","description":"Project path or repo root to search instead of the configured Codex home-session path","entry_type":"flag"},{"command":"f claude connect","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f claude connect","short":null,"long":"--json","description":"Emit machine-readable JSON for the selected session instead of resuming it","entry_type":"flag"},{"command":"f claude open","short":null,"long":null,"description":"Open a Codex session with fast repo-scoped recovery and reference unrolling","entry_type":"subcommand"},{"command":"f claude open","short":null,"long":"--path","description":"Project path to open from instead of the current directory","entry_type":"flag"},{"command":"f claude open","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f claude resolve","short":null,"long":null,"description":"Resolve how `f codex open` would interpret a query","entry_type":"subcommand"},{"command":"f claude resolve","short":null,"long":"--path","description":"Project path to resolve from instead of the current directory","entry_type":"flag"},{"command":"f claude resolve","short":null,"long":"--exact-cwd","description":"Restrict session lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f claude resolve","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude doctor","short":null,"long":null,"description":"Print effective Codex control-plane settings for this path","entry_type":"subcommand"},{"command":"f claude doctor","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude doctor","short":null,"long":"--assert-runtime","description":"Exit non-zero unless wrapper transport and runtime skills are active","entry_type":"flag"},{"command":"f claude doctor","short":null,"long":"--assert-schedule","description":"Exit non-zero unless the scheduled scorecard refresher is installed and loaded","entry_type":"flag"},{"command":"f claude doctor","short":null,"long":"--assert-learning","description":"Exit non-zero unless Flow has grounded learning data for this target","entry_type":"flag"},{"command":"f claude doctor","short":null,"long":"--assert-autonomous","description":"Exit non-zero unless runtime, schedule, and grounded learning are all active","entry_type":"flag"},{"command":"f claude doctor","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude eval","short":null,"long":null,"description":"Evaluate how well Flow-guided Codex usage is working for this repo/path","entry_type":"subcommand"},{"command":"f claude eval","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude eval","short":null,"long":"--limit","description":"Maximum number of recent logged events/outcomes to inspect","entry_type":"flag"},{"command":"f claude eval","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":null,"description":"Enable the global Codex wrapper/runtime path so Flow features are actually used","entry_type":"subcommand"},{"command":"f claude enable-global","short":null,"long":"--dry-run","description":"Show the resulting global config and actions without writing anything","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--install-launchd","description":"Also install the macOS launchd scorecard refresher","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--start-daemon","description":"Start codexd immediately after enabling the global config","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--sync-skills","description":"Sync any discovered external skill sources after enabling the config","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--full","description":"Shortcut for --install-launchd --start-daemon --sync-skills","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--minutes","description":"Launchd cadence in minutes (used with --install-launchd/--full)","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--limit","description":"Max logged events to scan per launchd run","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--max-targets","description":"Max repos to rebuild per launchd run","entry_type":"flag"},{"command":"f claude enable-global","short":null,"long":"--within-hours","description":"Recent-history window for launchd cron selection","entry_type":"flag"},{"command":"f claude daemon","short":null,"long":null,"description":"Manage the Flow codexd query daemon","entry_type":"subcommand"},{"command":"f claude daemon start","short":null,"long":null,"description":"Start codexd under Flow supervision","entry_type":"subcommand"},{"command":"f claude daemon stop","short":null,"long":null,"description":"Stop codexd","entry_type":"subcommand"},{"command":"f claude daemon restart","short":null,"long":null,"description":"Restart codexd","entry_type":"subcommand"},{"command":"f claude daemon status","short":null,"long":null,"description":"Show codexd status","entry_type":"subcommand"},{"command":"f claude memory","short":null,"long":null,"description":"Inspect or sync the Jazz2-backed Codex memory mirror","entry_type":"subcommand"},{"command":"f claude memory status","short":null,"long":null,"description":"Show memory mirror status and counts","entry_type":"subcommand"},{"command":"f claude memory status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude memory sync","short":null,"long":null,"description":"Sync recent Codex skill-eval logs into the Jazz2-backed memory mirror","entry_type":"subcommand"},{"command":"f claude memory sync","short":null,"long":"--limit","description":"Maximum number of recent events and outcomes to ingest","entry_type":"flag"},{"command":"f claude memory sync","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude memory query","short":null,"long":null,"description":"Query compact repo/code memory facts for a path","entry_type":"subcommand"},{"command":"f claude memory query","short":null,"long":"--path","description":"Project path or repo root to query","entry_type":"flag"},{"command":"f claude memory query","short":null,"long":"--limit","description":"Maximum number of fact hits to include","entry_type":"flag"},{"command":"f claude memory query","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude memory recent","short":null,"long":null,"description":"Show recent memory rows, optionally scoped to a repo/path","entry_type":"subcommand"},{"command":"f claude memory recent","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude memory recent","short":null,"long":"--limit","description":"Maximum number of rows to print","entry_type":"flag"},{"command":"f claude memory recent","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude telemetry","short":null,"long":null,"description":"Export redacted Codex workflow telemetry to configured Maple endpoints","entry_type":"subcommand"},{"command":"f claude telemetry status","short":null,"long":null,"description":"Show Codex telemetry export config and current forwarder state","entry_type":"subcommand"},{"command":"f claude telemetry status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude telemetry flush","short":null,"long":null,"description":"Flush recently logged Codex telemetry to configured Maple endpoints once","entry_type":"subcommand"},{"command":"f claude telemetry flush","short":null,"long":"--limit","description":"Maximum number of unseen events/outcomes to export in one pass","entry_type":"flag"},{"command":"f claude telemetry flush","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude trace","short":null,"long":null,"description":"Inspect Flow-managed Codex traces for the current or a specific session","entry_type":"subcommand"},{"command":"f claude trace status","short":null,"long":null,"description":"Show Maple trace read status and configured credentials","entry_type":"subcommand"},{"command":"f claude trace status","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude trace current-session","short":null,"long":null,"description":"Inspect the trace associated with the active Flow-managed Codex session","entry_type":"subcommand"},{"command":"f claude trace current-session","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f claude trace current-session","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude trace inspect","short":null,"long":null,"description":"Inspect a specific trace id","entry_type":"subcommand"},{"command":"f claude trace inspect","short":null,"long":"--flush","description":"Flush recent Flow Codex telemetry before inspecting the trace","entry_type":"flag"},{"command":"f claude trace inspect","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude skill-eval","short":null,"long":null,"description":"Build and inspect local Codex skill scorecards from Flow history","entry_type":"subcommand"},{"command":"f claude skill-eval run","short":null,"long":null,"description":"Rebuild the local scorecard for this repo/path from recent Flow Codex history","entry_type":"subcommand"},{"command":"f claude skill-eval run","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude skill-eval run","short":null,"long":"--limit","description":"Maximum number of recent events to use when rebuilding","entry_type":"flag"},{"command":"f claude skill-eval run","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude skill-eval show","short":null,"long":null,"description":"Show the current scorecard for this repo/path","entry_type":"subcommand"},{"command":"f claude skill-eval show","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude skill-eval show","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude skill-eval events","short":null,"long":null,"description":"Show recent logged skill-eval events","entry_type":"subcommand"},{"command":"f claude skill-eval events","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude skill-eval events","short":null,"long":"--limit","description":"Maximum number of events to print","entry_type":"flag"},{"command":"f claude skill-eval events","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude skill-eval cron","short":null,"long":null,"description":"Refresh scorecards for the most recent repos seen in Flow Codex history","entry_type":"subcommand"},{"command":"f claude skill-eval cron","short":null,"long":"--limit","description":"Maximum number of logged events to scan for target repos","entry_type":"flag"},{"command":"f claude skill-eval cron","short":null,"long":"--max-targets","description":"Maximum number of repo targets to rebuild in one pass","entry_type":"flag"},{"command":"f claude skill-eval cron","short":null,"long":"--within-hours","description":"Only consider repos seen within this many recent hours","entry_type":"flag"},{"command":"f claude skill-eval cron","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude skill-source","short":null,"long":null,"description":"Discover and sync external Codex skill sources","entry_type":"subcommand"},{"command":"f claude skill-source list","short":null,"long":null,"description":"List discovered external skills available for Codex runtime injection","entry_type":"subcommand"},{"command":"f claude skill-source list","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude skill-source list","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude skill-source sync","short":null,"long":null,"description":"Copy discovered external skills into ~/.codex/skills for persistent use","entry_type":"subcommand"},{"command":"f claude skill-source sync","short":null,"long":"--path","description":"Project path to inspect instead of the current directory","entry_type":"flag"},{"command":"f claude skill-source sync","short":null,"long":"--skill","description":"Restrict sync to the named discovered skills","entry_type":"flag"},{"command":"f claude skill-source sync","short":null,"long":"--force","description":"Overwrite an existing ~/.codex/skills/<name> directory","entry_type":"flag"},{"command":"f claude runtime","short":null,"long":null,"description":"Inspect or manage Flow-managed Codex runtime helpers","entry_type":"subcommand"},{"command":"f claude runtime show","short":null,"long":null,"description":"Show recent Flow-managed Codex runtime skill activations","entry_type":"subcommand"},{"command":"f claude runtime clear","short":null,"long":null,"description":"Remove Flow-managed runtime skill state and stale symlinks","entry_type":"subcommand"},{"command":"f claude runtime write-plan","short":null,"long":null,"description":"Write a markdown plan to ~/plan and print the final path","entry_type":"subcommand"},{"command":"f claude runtime write-plan","short":null,"long":"--title","description":"Human-readable title used to derive the filename","entry_type":"flag"},{"command":"f claude runtime write-plan","short":null,"long":"--stem","description":"Explicit filename stem to use instead of deriving from the title","entry_type":"flag"},{"command":"f claude runtime write-plan","short":null,"long":"--dir","description":"Destination directory (defaults to ~/plan)","entry_type":"flag"},{"command":"f claude runtime write-plan","short":null,"long":"--source-session","description":"Codex session id to append as a footer (defaults to $CODEX_THREAD_ID)","entry_type":"flag"},{"command":"f claude find","short":null,"long":null,"description":"Search Codex sessions by prompt text and resume the best match","entry_type":"subcommand"},{"command":"f claude find","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f claude find","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f claude findAndCopy","short":null,"long":null,"description":"Search Codex sessions by prompt text and copy the best match to clipboard","entry_type":"subcommand"},{"command":"f claude findAndCopy","short":null,"long":"--path","description":"Limit search to sessions from this path or repo subtree (default: all Codex sessions)","entry_type":"flag"},{"command":"f claude findAndCopy","short":null,"long":"--exact-cwd","description":"Restrict --path lookup to an exact cwd instead of a repo-tree prefix","entry_type":"flag"},{"command":"f claude copy","short":null,"long":null,"description":"Copy session history to clipboard","entry_type":"subcommand"},{"command":"f claude context","short":null,"long":null,"description":"Copy last prompt and response to clipboard (for context passing). Usage: f ai claude context [session] [path] [count]","entry_type":"subcommand"},{"command":"f claude show","short":null,"long":null,"description":"Print a cleaned session excerpt to stdout","entry_type":"subcommand"},{"command":"f claude show","short":null,"long":"--path","description":"Path to project directory (default: current directory)","entry_type":"flag"},{"command":"f claude show","short":null,"long":"--count","description":"Number of exchanges to include (default: 12)","entry_type":"flag"},{"command":"f claude show","short":null,"long":"--full","description":"Print the full cleaned transcript instead of just the trailing exchanges","entry_type":"flag"},{"command":"f claude recover","short":null,"long":null,"description":"Recover recent Codex session context for a repo or subpath","entry_type":"subcommand"},{"command":"f claude recover","short":null,"long":"--path","description":"Path to recover context for (default: current directory)","entry_type":"flag"},{"command":"f claude recover","short":null,"long":"--exact-cwd","description":"Restrict lookup to an exact cwd match instead of a repo-tree prefix","entry_type":"flag"},{"command":"f claude recover","short":null,"long":"--limit","description":"Maximum number of candidate sessions to return","entry_type":"flag"},{"command":"f claude recover","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f claude recover","short":null,"long":"--summary-only","description":"Emit only the compact recovery summary for prompt injection","entry_type":"flag"},{"command":"f env","short":null,"long":null,"description":"Manage project env vars and cloud sync.","entry_type":"subcommand"},{"command":"f env sync","short":null,"long":null,"description":"Sync project settings and set up autonomous agent workflow","entry_type":"subcommand"},{"command":"f env unlock","short":null,"long":null,"description":"Unlock env read access (Touch ID on macOS)","entry_type":"subcommand"},{"command":"f env new","short":null,"long":null,"description":"Create a new env token from available templates","entry_type":"subcommand"},{"command":"f env login","short":null,"long":null,"description":"Authenticate with cloud to fetch env vars","entry_type":"subcommand"},{"command":"f env pull","short":null,"long":null,"description":"Fetch env vars from cloud and write to .env","entry_type":"subcommand"},{"command":"f env pull","short":"-e","long":"--environment","description":"Environment to fetch (dev, staging, production)","entry_type":"flag"},{"command":"f env push","short":null,"long":null,"description":"Push local .env to cloud","entry_type":"subcommand"},{"command":"f env push","short":"-e","long":"--environment","description":"Environment to push to (dev, staging, production)","entry_type":"flag"},{"command":"f env guide","short":null,"long":null,"description":"Guided prompt to set required env vars from flow.toml","entry_type":"subcommand"},{"command":"f env guide","short":"-e","long":"--environment","description":"Environment to set in (dev, staging, production)","entry_type":"flag"},{"command":"f env apply","short":null,"long":null,"description":"Apply env vars from cloud to the configured Cloudflare worker","entry_type":"subcommand"},{"command":"f env bootstrap","short":null,"long":null,"description":"Bootstrap Cloudflare secrets from flow.toml (interactive)","entry_type":"subcommand"},{"command":"f env setup","short":null,"long":null,"description":"Interactive env setup (uses flow.toml when configured)","entry_type":"subcommand"},{"command":"f env setup","short":"-f","long":"--env-file","description":"Optional .env file path to preselect","entry_type":"flag"},{"command":"f env setup","short":"-e","long":"--environment","description":"Optional environment to preselect","entry_type":"flag"},{"command":"f env list","short":null,"long":null,"description":"List env vars for this project","entry_type":"subcommand"},{"command":"f env list","short":"-e","long":"--environment","description":"Environment to list (dev, staging, production)","entry_type":"flag"},{"command":"f env set","short":null,"long":null,"description":"Set a personal env var (default backend)","entry_type":"subcommand"},{"command":"f env set","short":null,"long":"--personal","description":"Compatibility flag (ignored; set always targets personal env)","entry_type":"flag"},{"command":"f env delete","short":null,"long":null,"description":"Delete personal env var(s)","entry_type":"subcommand"},{"command":"f env project","short":null,"long":null,"description":"Manage project-scoped env vars","entry_type":"subcommand"},{"command":"f env project set","short":null,"long":null,"description":"Set a project-scoped env var","entry_type":"subcommand"},{"command":"f env project set","short":"-e","long":"--environment","description":"Environment (dev, staging, production)","entry_type":"flag"},{"command":"f env project delete","short":null,"long":null,"description":"Delete project-scoped env var(s)","entry_type":"subcommand"},{"command":"f env project delete","short":"-e","long":"--environment","description":"Environment (dev, staging, production)","entry_type":"flag"},{"command":"f env project list","short":null,"long":null,"description":"List project env vars","entry_type":"subcommand"},{"command":"f env project list","short":"-e","long":"--environment","description":"Environment (dev, staging, production)","entry_type":"flag"},{"command":"f env status","short":null,"long":null,"description":"Show current auth status","entry_type":"subcommand"},{"command":"f env get","short":null,"long":null,"description":"Get specific env var(s) and print to stdout","entry_type":"subcommand"},{"command":"f env get","short":null,"long":"--personal","description":"Fetch from personal env vars instead of project","entry_type":"flag"},{"command":"f env get","short":"-e","long":"--environment","description":"Environment to fetch from (dev, staging, production)","entry_type":"flag"},{"command":"f env get","short":"-f","long":"--format","description":"Output format: env (KEY=VALUE), json, or value (just the value, single key only)","entry_type":"flag"},{"command":"f env run","short":null,"long":null,"description":"Run a command with env vars injected from cloud","entry_type":"subcommand"},{"command":"f env run","short":null,"long":"--personal","description":"Fetch from personal env vars instead of project","entry_type":"flag"},{"command":"f env run","short":"-e","long":"--environment","description":"Environment to fetch from (dev, staging, production)","entry_type":"flag"},{"command":"f env run","short":"-k","long":"--keys","description":"Specific keys to inject (if empty, injects all)","entry_type":"flag"},{"command":"f env keys","short":null,"long":null,"description":"Show configured env keys from flow.toml","entry_type":"subcommand"},{"command":"f env token","short":null,"long":null,"description":"Manage service tokens for host deployments","entry_type":"subcommand"},{"command":"f env token create","short":null,"long":null,"description":"Create a new service token for a project","entry_type":"subcommand"},{"command":"f env token create","short":"-n","long":"--name","description":"Token name (e.g., \"pulse-production\")","entry_type":"flag"},{"command":"f env token create","short":"-p","long":"--permissions","description":"Permissions: read, write, or admin","entry_type":"flag"},{"command":"f env token list","short":null,"long":null,"description":"List service tokens","entry_type":"subcommand"},{"command":"f env token revoke","short":null,"long":null,"description":"Revoke a service token","entry_type":"subcommand"},{"command":"f otp","short":null,"long":null,"description":"Fetch one-time passwords from 1Password Connect.","entry_type":"subcommand"},{"command":"f otp get","short":null,"long":null,"description":"Get a TOTP code from 1Password Connect","entry_type":"subcommand"},{"command":"f otp get","short":null,"long":"--field","description":"Optional field label to select when multiple TOTP fields exist","entry_type":"flag"},{"command":"f auth","short":null,"long":null,"description":"Authenticate Flow AI via myflow.","entry_type":"subcommand"},{"command":"f auth","short":null,"long":"--api-url","description":"Override API base URL for myflow (defaults to https://myflow.sh)","entry_type":"flag"},{"command":"f services","short":null,"long":null,"description":"Onboard third-party services (Stripe, etc.) with guided env setup.","entry_type":"subcommand"},{"command":"f services stripe","short":null,"long":null,"description":"Set up Stripe env vars with guided prompts","entry_type":"subcommand"},{"command":"f services stripe","short":"-p","long":"--path","description":"Path to the project root (defaults to current directory)","entry_type":"flag"},{"command":"f services stripe","short":"-e","long":"--environment","description":"Environment to store vars in (dev, staging, production)","entry_type":"flag"},{"command":"f services stripe","short":null,"long":"--mode","description":"Stripe mode (test or live)","entry_type":"flag"},{"command":"f services stripe","short":null,"long":"--force","description":"Prompt even if keys are already set","entry_type":"flag"},{"command":"f services stripe","short":null,"long":"--apply","description":"Apply env vars to Cloudflare after setting them","entry_type":"flag"},{"command":"f services stripe","short":null,"long":"--no-apply","description":"Skip applying env vars to Cloudflare","entry_type":"flag"},{"command":"f services list","short":null,"long":null,"description":"List available service setup flows","entry_type":"subcommand"},{"command":"f macos","short":null,"long":null,"description":"Manage macOS launch agents and daemons.","entry_type":"subcommand"},{"command":"f macos list","short":null,"long":null,"description":"List all launchd services","entry_type":"subcommand"},{"command":"f macos list","short":null,"long":"--user","description":"Only show user agents","entry_type":"flag"},{"command":"f macos list","short":null,"long":"--system","description":"Only show system agents/daemons","entry_type":"flag"},{"command":"f macos list","short":null,"long":"--json","description":"Output as JSON","entry_type":"flag"},{"command":"f macos status","short":null,"long":null,"description":"Show running non-Apple services","entry_type":"subcommand"},{"command":"f macos audit","short":null,"long":null,"description":"Audit services with recommendations","entry_type":"subcommand"},{"command":"f macos audit","short":null,"long":"--json","description":"Output as JSON","entry_type":"flag"},{"command":"f macos info","short":null,"long":null,"description":"Show detailed info about a service","entry_type":"subcommand"},{"command":"f macos disable","short":null,"long":null,"description":"Disable a service","entry_type":"subcommand"},{"command":"f macos disable","short":"-y","long":"--yes","description":"Skip confirmation prompt","entry_type":"flag"},{"command":"f macos enable","short":null,"long":null,"description":"Enable a service","entry_type":"subcommand"},{"command":"f macos clean","short":null,"long":null,"description":"Disable known bloatware services","entry_type":"subcommand"},{"command":"f macos clean","short":null,"long":"--dry-run","description":"Only show what would be done","entry_type":"flag"},{"command":"f macos clean","short":"-y","long":"--yes","description":"Skip confirmation prompt","entry_type":"flag"},{"command":"f ssh","short":null,"long":null,"description":"Manage SSH keys via the cloud backend.","entry_type":"subcommand"},{"command":"f ssh setup","short":null,"long":null,"description":"Generate a new SSH keypair and store it in cloud personal env vars","entry_type":"subcommand"},{"command":"f ssh setup","short":null,"long":"--name","description":"Optional key name (default: \"default\")","entry_type":"flag"},{"command":"f ssh setup","short":null,"long":"--no-unlock","description":"Skip automatically unlocking the key after setup","entry_type":"flag"},{"command":"f ssh unlock","short":null,"long":null,"description":"Unlock the SSH key from cloud and load it into the Flow SSH agent","entry_type":"subcommand"},{"command":"f ssh unlock","short":null,"long":"--name","description":"Optional key name (default: \"default\")","entry_type":"flag"},{"command":"f ssh unlock","short":null,"long":"--ttl-hours","description":"TTL for ssh-agent in hours (default: 24)","entry_type":"flag"},{"command":"f ssh status","short":null,"long":null,"description":"Show whether the Flow SSH agent and key are available","entry_type":"subcommand"},{"command":"f ssh status","short":null,"long":"--name","description":"Optional key name (default: \"default\")","entry_type":"flag"},{"command":"f todo","short":null,"long":null,"description":"Manage project todos.","entry_type":"subcommand"},{"command":"f todo bike","short":null,"long":null,"description":"Open the project Bike file","entry_type":"subcommand"},{"command":"f todo add","short":null,"long":null,"description":"Add a new todo","entry_type":"subcommand"},{"command":"f todo add","short":"-n","long":"--note","description":"Optional note to store with the todo","entry_type":"flag"},{"command":"f todo add","short":null,"long":"--session","description":"Attach a specific AI session reference (provider:session_id)","entry_type":"flag"},{"command":"f todo add","short":null,"long":"--no-session","description":"Skip attaching the most recent AI session","entry_type":"flag"},{"command":"f todo add","short":"-s","long":"--status","description":"Initial status (pending, in-progress, completed, blocked)","entry_type":"flag"},{"command":"f todo list","short":null,"long":null,"description":"List todos (active by default)","entry_type":"subcommand"},{"command":"f todo list","short":null,"long":"--all","description":"Include completed todos","entry_type":"flag"},{"command":"f todo done","short":null,"long":null,"description":"Mark a todo as completed","entry_type":"subcommand"},{"command":"f todo edit","short":null,"long":null,"description":"Edit a todo","entry_type":"subcommand"},{"command":"f todo edit","short":"-t","long":"--title","description":"Update the title","entry_type":"flag"},{"command":"f todo edit","short":"-s","long":"--status","description":"Update the status","entry_type":"flag"},{"command":"f todo edit","short":"-n","long":"--note","description":"Update the note (empty clears)","entry_type":"flag"},{"command":"f todo remove","short":null,"long":null,"description":"Remove a todo","entry_type":"subcommand"},{"command":"f ext","short":null,"long":null,"description":"Copy an external dependency into ext/ and ignore it.","entry_type":"subcommand"},{"command":"f skills","short":null,"long":null,"description":"Manage Codex skills (.ai/skills/).","entry_type":"subcommand"},{"command":"f skills list","short":null,"long":null,"description":"List all skills for this project","entry_type":"subcommand"},{"command":"f skills new","short":null,"long":null,"description":"Create a new skill","entry_type":"subcommand"},{"command":"f skills new","short":"-d","long":"--description","description":"Short description of what the skill does","entry_type":"flag"},{"command":"f skills show","short":null,"long":null,"description":"Show skill details","entry_type":"subcommand"},{"command":"f skills edit","short":null,"long":null,"description":"Edit a skill in your editor","entry_type":"subcommand"},{"command":"f skills remove","short":null,"long":null,"description":"Remove a skill","entry_type":"subcommand"},{"command":"f skills install","short":null,"long":null,"description":"Install a curated skill from the registry","entry_type":"subcommand"},{"command":"f skills publish","short":null,"long":null,"description":"Publish a local skill to the shared registry","entry_type":"subcommand"},{"command":"f skills search","short":null,"long":null,"description":"Search for skills in the remote registry","entry_type":"subcommand"},{"command":"f skills sync","short":null,"long":null,"description":"Sync flow.toml tasks as skills","entry_type":"subcommand"},{"command":"f skills reload","short":null,"long":null,"description":"Force Codex app-server to rescan skills from disk for this cwd","entry_type":"subcommand"},{"command":"f skills fetch","short":null,"long":null,"description":"Fetch dependency skills via seq scraper integration","entry_type":"subcommand"},{"command":"f skills fetch","short":null,"long":"--seq-repo","description":"Path to seq repo (default: ~/code/seq)","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--script-path","description":"Path to seq teach script (overrides --seq-repo)","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--scraper-base-url","description":"Scraper daemon/API base URL","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--scraper-api-key","description":"Scraper API token","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--out-dir","description":"Output directory for generated skills (relative to repo root)","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--cache-ttl-hours","description":"Cache TTL in hours for scraper responses","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--allow-direct-fallback","description":"Allow direct fetch fallback when scraper queue/api is unavailable","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--no-mem-events","description":"Disable seq.mem JSON event emission","entry_type":"flag"},{"command":"f skills fetch","short":null,"long":"--mem-events-path","description":"Override seq.mem JSONEachRow path","entry_type":"flag"},{"command":"f skills fetch dep","short":null,"long":null,"description":"Generate skills for one or more dependencies","entry_type":"subcommand"},{"command":"f skills fetch dep","short":null,"long":"--ecosystem","description":"Force ecosystem for all deps","entry_type":"flag"},{"command":"f skills fetch dep","short":null,"long":"--force","description":"Bypass cache and scrape fresh","entry_type":"flag"},{"command":"f skills fetch auto","short":null,"long":null,"description":"Auto-discover dependencies from manifests and generate skills","entry_type":"subcommand"},{"command":"f skills fetch auto","short":null,"long":"--top","description":"Max dependencies per ecosystem","entry_type":"flag"},{"command":"f skills fetch auto","short":null,"long":"--ecosystems","description":"Comma-separated ecosystem list (npm,pypi,cargo,swift)","entry_type":"flag"},{"command":"f skills fetch auto","short":null,"long":"--force","description":"Bypass cache and scrape fresh","entry_type":"flag"},{"command":"f skills fetch url","short":null,"long":null,"description":"Generate skills from one or more URLs","entry_type":"subcommand"},{"command":"f skills fetch url","short":null,"long":"--name","description":"Skill name override","entry_type":"flag"},{"command":"f skills fetch url","short":null,"long":"--force","description":"Bypass cache and scrape fresh","entry_type":"flag"},{"command":"f url","short":null,"long":null,"description":"Inspect a URL into a thin, AI-friendly summary.","entry_type":"subcommand"},{"command":"f url inspect","short":null,"long":null,"description":"Inspect a URL and return a compact normalized summary","entry_type":"subcommand"},{"command":"f url inspect","short":null,"long":"--json","description":"Print machine-readable JSON","entry_type":"flag"},{"command":"f url inspect","short":null,"long":"--full","description":"Include the full markdown/content body when available","entry_type":"flag"},{"command":"f url inspect","short":null,"long":"--provider","description":"Provider to use. `auto` tries Cloudflare first, then scraper, then direct fetch","entry_type":"flag"},{"command":"f url inspect","short":null,"long":"--timeout-s","description":"Request timeout in seconds","entry_type":"flag"},{"command":"f url crawl","short":null,"long":null,"description":"Crawl a site and return a compact multi-page summary","entry_type":"subcommand"},{"command":"f url crawl","short":null,"long":"--json","description":"Print machine-readable JSON","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--full","description":"Include full markdown for returned records","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--limit","description":"Maximum number of pages to crawl","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--depth","description":"Maximum crawl depth from the starting URL","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--records","description":"Maximum number of completed records to return in the final summary","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--source","description":"Crawl source: all discovered URLs, only sitemaps, or only links","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--render","description":"Render pages in a browser before extraction. Disabled by default for faster static crawls","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--include-external-links","description":"Include external links during crawl","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--include-subdomains","description":"Include subdomains during crawl","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--include-pattern","description":"Only include URLs matching these wildcard patterns","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--exclude-pattern","description":"Exclude URLs matching these wildcard patterns","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--max-age-s","description":"Max crawl cache age in seconds","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--wait-timeout-s","description":"Max time to wait for crawl completion in seconds","entry_type":"flag"},{"command":"f url crawl","short":null,"long":"--poll-interval-s","description":"Poll interval while waiting for completion, in seconds","entry_type":"flag"},{"command":"f deps","short":null,"long":null,"description":"Install or update project dependencies.","entry_type":"subcommand"},{"command":"f deps","short":null,"long":"--manager","description":"Force a package manager instead of auto-detect","entry_type":"flag"},{"command":"f deps install","short":null,"long":null,"description":"Install dependencies","entry_type":"subcommand"},{"command":"f deps update","short":null,"long":null,"description":"Smart dependency updates based on inferred ecosystem","entry_type":"subcommand"},{"command":"f deps update","short":null,"long":"--latest","description":"Upgrade to latest versions when supported by ecosystem tooling","entry_type":"flag"},{"command":"f deps update","short":null,"long":"--dry-run","description":"Print planned commands without executing them","entry_type":"flag"},{"command":"f deps update","short":"-y","long":"--yes","description":"Skip confirmation prompt","entry_type":"flag"},{"command":"f deps update","short":null,"long":"--no-tui","description":"Disable OpenTUI confirmation and use plain prompt","entry_type":"flag"},{"command":"f deps update","short":null,"long":"--ecosystem","description":"Force a specific ecosystem instead of auto-detect","entry_type":"flag"},{"command":"f deps update","short":null,"long":"--manager","description":"Force JS package manager (only used for js ecosystem)","entry_type":"flag"},{"command":"f deps pick","short":null,"long":null,"description":"Fuzzy-pick a dependency or linked repo and fetch it to ~/repos","entry_type":"subcommand"},{"command":"f deps repo","short":null,"long":null,"description":"Add an external repo dependency and link it under .ai/repos","entry_type":"subcommand"},{"command":"f deps repo","short":null,"long":"--root","description":"Root directory for clones (default: ~/repos)","entry_type":"flag"},{"command":"f deps repo","short":null,"long":"--private","description":"Create a private fork in your GitHub account and set origin","entry_type":"flag"},{"command":"f db","short":null,"long":null,"description":"Manage databases (Jazz, Postgres).","entry_type":"subcommand"},{"command":"f db jazz","short":null,"long":null,"description":"Jazz2 app credentials and env wiring","entry_type":"subcommand"},{"command":"f db jazz new","short":null,"long":null,"description":"Create a new Jazz2 app credential set and store env vars","entry_type":"subcommand"},{"command":"f db jazz new","short":null,"long":"--kind","description":"What the app credentials will be used for","entry_type":"flag"},{"command":"f db jazz new","short":null,"long":"--name","description":"Optional name for the app","entry_type":"flag"},{"command":"f db jazz new","short":null,"long":"--peer","description":"Optional sync server URL (ws/wss urls are normalized to http/https)","entry_type":"flag"},{"command":"f db jazz new","short":null,"long":"--api-key","description":"Optional Jazz API key (for hosted cloud routing)","entry_type":"flag"},{"command":"f db jazz new","short":"-e","long":"--environment","description":"Environment to store in (dev, staging, production)","entry_type":"flag"},{"command":"f db postgres","short":null,"long":null,"description":"Postgres workflows (migrations/generation)","entry_type":"subcommand"},{"command":"f db postgres generate","short":null,"long":null,"description":"Generate Drizzle migrations for the configured Postgres project","entry_type":"subcommand"},{"command":"f db postgres generate","short":null,"long":"--project","description":"Override the Postgres project directory (defaults to ~/org/la/la/server)","entry_type":"flag"},{"command":"f db postgres migrate","short":null,"long":null,"description":"Apply Drizzle migrations for the configured Postgres project","entry_type":"subcommand"},{"command":"f db postgres migrate","short":null,"long":"--project","description":"Override the Postgres project directory (defaults to ~/org/la/la/server)","entry_type":"flag"},{"command":"f db postgres migrate","short":null,"long":"--database-url","description":"Explicit DATABASE_URL (falls back to env/.env/Planetscale env vars)","entry_type":"flag"},{"command":"f db postgres migrate","short":null,"long":"--generate","description":"Generate migrations before applying them","entry_type":"flag"},{"command":"f tools","short":null,"long":null,"description":"Manage AI tools (.ai/tools/*.ts).","entry_type":"subcommand"},{"command":"f tools list","short":null,"long":null,"description":"List all tools for this project","entry_type":"subcommand"},{"command":"f tools run","short":null,"long":null,"description":"Run a tool","entry_type":"subcommand"},{"command":"f tools new","short":null,"long":null,"description":"Create a new tool","entry_type":"subcommand"},{"command":"f tools new","short":"-d","long":"--description","description":"Short description of what the tool does","entry_type":"flag"},{"command":"f tools new","short":null,"long":"--ai","description":"Use AI (localcode) to generate the tool implementation","entry_type":"flag"},{"command":"f tools edit","short":null,"long":null,"description":"Edit a tool in your editor","entry_type":"subcommand"},{"command":"f tools remove","short":null,"long":null,"description":"Remove a tool","entry_type":"subcommand"},{"command":"f notify","short":null,"long":null,"description":"Send a proposal notification to Lin for approval.","entry_type":"subcommand"},{"command":"f notify","short":"-t","long":"--title","description":"Title of the proposal (shown in widget header)","entry_type":"flag"},{"command":"f notify","short":"-c","long":"--context","description":"Optional context or description","entry_type":"flag"},{"command":"f notify","short":"-e","long":"--expires","description":"Expiration time in seconds (default: 300 = 5 minutes)","entry_type":"flag"},{"command":"f commits","short":null,"long":null,"description":"Browse and analyze git commits with AI session metadata.","entry_type":"subcommand"},{"command":"f commits","short":"-n","long":"--limit","description":"Number of commits to show (default: 100)","entry_type":"flag"},{"command":"f commits","short":null,"long":"--all","description":"Show commits across all branches","entry_type":"flag"},{"command":"f commits top","short":null,"long":null,"description":"List notable commits","entry_type":"subcommand"},{"command":"f commits mark","short":null,"long":null,"description":"Mark a commit as notable","entry_type":"subcommand"},{"command":"f commits unmark","short":null,"long":null,"description":"Remove a commit from notable list","entry_type":"subcommand"},{"command":"f seq-rpc","short":null,"long":null,"description":"Call seqd RPC v1 via native Rust client.","entry_type":"subcommand"},{"command":"f seq-rpc","short":null,"long":"--socket","description":"Path to seqd Unix socket (default: $SEQ_SOCKET_PATH, then /tmp/seqd.sock)","entry_type":"flag"},{"command":"f seq-rpc","short":null,"long":"--timeout-ms","description":"Read/write timeout in milliseconds","entry_type":"flag"},{"command":"f seq-rpc","short":null,"long":"--pretty","description":"Pretty-print JSON response","entry_type":"flag"},{"command":"f seq-rpc ping","short":null,"long":null,"description":"Health check","entry_type":"subcommand"},{"command":"f seq-rpc ping","short":null,"long":"--request-id","description":"Caller-owned id for request correlation","entry_type":"flag"},{"command":"f seq-rpc ping","short":null,"long":"--run-id","description":"Caller run id","entry_type":"flag"},{"command":"f seq-rpc ping","short":null,"long":"--tool-call-id","description":"Caller tool call id","entry_type":"flag"},{"command":"f seq-rpc app-state","short":null,"long":null,"description":"Current/previous foreground app snapshot","entry_type":"subcommand"},{"command":"f seq-rpc app-state","short":null,"long":"--request-id","description":"Caller-owned id for request correlation","entry_type":"flag"},{"command":"f seq-rpc app-state","short":null,"long":"--run-id","description":"Caller run id","entry_type":"flag"},{"command":"f seq-rpc app-state","short":null,"long":"--tool-call-id","description":"Caller tool call id","entry_type":"flag"},{"command":"f seq-rpc perf","short":null,"long":null,"description":"Daemon perf/rusage snapshot","entry_type":"subcommand"},{"command":"f seq-rpc perf","short":null,"long":"--request-id","description":"Caller-owned id for request correlation","entry_type":"flag"},{"command":"f seq-rpc perf","short":null,"long":"--run-id","description":"Caller run id","entry_type":"flag"},{"command":"f seq-rpc perf","short":null,"long":"--tool-call-id","description":"Caller tool call id","entry_type":"flag"},{"command":"f seq-rpc open-app","short":null,"long":null,"description":"Open application by name","entry_type":"subcommand"},{"command":"f seq-rpc open-app","short":null,"long":"--request-id","description":"Caller-owned id for request correlation","entry_type":"flag"},{"command":"f seq-rpc open-app","short":null,"long":"--run-id","description":"Caller run id","entry_type":"flag"},{"command":"f seq-rpc open-app","short":null,"long":"--tool-call-id","description":"Caller tool call id","entry_type":"flag"},{"command":"f seq-rpc open-app-toggle","short":null,"long":null,"description":"Toggle application by name","entry_type":"subcommand"},{"command":"f seq-rpc open-app-toggle","short":null,"long":"--request-id","description":"Caller-owned id for request correlation","entry_type":"flag"},{"command":"f seq-rpc open-app-toggle","short":null,"long":"--run-id","description":"Caller run id","entry_type":"flag"},{"command":"f seq-rpc open-app-toggle","short":null,"long":"--tool-call-id","description":"Caller tool call id","entry_type":"flag"},{"command":"f seq-rpc screenshot","short":null,"long":null,"description":"Save screenshot to path","entry_type":"subcommand"},{"command":"f seq-rpc screenshot","short":null,"long":"--request-id","description":"Caller-owned id for request correlation","entry_type":"flag"},{"command":"f seq-rpc screenshot","short":null,"long":"--run-id","description":"Caller run id","entry_type":"flag"},{"command":"f seq-rpc screenshot","short":null,"long":"--tool-call-id","description":"Caller tool call id","entry_type":"flag"},{"command":"f seq-rpc rpc","short":null,"long":null,"description":"Raw operation and optional JSON args","entry_type":"subcommand"},{"command":"f seq-rpc rpc","short":null,"long":"--args-json","description":"Optional JSON args payload","entry_type":"flag"},{"command":"f seq-rpc rpc","short":null,"long":"--request-id","description":"Caller-owned id for request correlation","entry_type":"flag"},{"command":"f seq-rpc rpc","short":null,"long":"--run-id","description":"Caller run id","entry_type":"flag"},{"command":"f seq-rpc rpc","short":null,"long":"--tool-call-id","description":"Caller tool call id","entry_type":"flag"},{"command":"f explain-commits","short":null,"long":null,"description":"Generate AI explanations for recent commits.","entry_type":"subcommand"},{"command":"f explain-commits","short":null,"long":"--force","description":"Re-explain even if already processed","entry_type":"flag"},{"command":"f explain-commits","short":null,"long":"--out-dir","description":"Output directory (relative to repo root unless absolute)","entry_type":"flag"},{"command":"f setup","short":null,"long":null,"description":"Bootstrap project and run setup task or aliases.","entry_type":"subcommand"},{"command":"f setup","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f agents","short":null,"long":null,"description":"Invoke gen AI agents.","entry_type":"subcommand"},{"command":"f agents list","short":null,"long":null,"description":"List available agents","entry_type":"subcommand"},{"command":"f agents run","short":null,"long":null,"description":"Run an agent with a prompt","entry_type":"subcommand"},{"command":"f agents global","short":null,"long":null,"description":"Run a global agent (prompt optional)","entry_type":"subcommand"},{"command":"f agents copy","short":null,"long":null,"description":"Copy agent instructions to clipboard (fuzzy select)","entry_type":"subcommand"},{"command":"f agents rules","short":null,"long":null,"description":"Switch agents.md profile (fuzzy select if not provided)","entry_type":"subcommand"},{"command":"f hive","short":null,"long":null,"description":"Manage and run hive agents.","entry_type":"subcommand"},{"command":"f hive list","short":null,"long":null,"description":"List available hive agents","entry_type":"subcommand"},{"command":"f hive run","short":null,"long":null,"description":"Run a hive agent with a prompt","entry_type":"subcommand"},{"command":"f hive new","short":null,"long":null,"description":"Create a new agent spec","entry_type":"subcommand"},{"command":"f hive new","short":"-g","long":"--global","description":"Create as global agent (default: project-local)","entry_type":"flag"},{"command":"f hive edit","short":null,"long":null,"description":"Edit an agent spec file","entry_type":"subcommand"},{"command":"f hive show","short":null,"long":null,"description":"Show an agent's spec","entry_type":"subcommand"},{"command":"f sync","short":null,"long":null,"description":"Sync git repo: pull + upstream merge (push optional).","entry_type":"subcommand"},{"command":"f sync","short":"-r","long":"--rebase","description":"Use rebase instead of merge when pulling","entry_type":"flag"},{"command":"f sync","short":null,"long":"--push","description":"Push to configured git remote after sync (default: false)","entry_type":"flag"},{"command":"f sync","short":null,"long":"--no-push","description":"Skip pushing to configured git remote (legacy; default is already no push)","entry_type":"flag"},{"command":"f sync","short":"-s","long":"--stash","description":"Auto-stash uncommitted changes (default: true)","entry_type":"flag"},{"command":"f sync","short":null,"long":"--stash-commits","description":"Stash local JJ commits to a bookmark before syncing (JJ-only)","entry_type":"flag"},{"command":"f sync","short":null,"long":"--allow-queue","description":"Allow sync/rebase even when commit queue is non-empty","entry_type":"flag"},{"command":"f sync","short":null,"long":"--create-repo","description":"Create origin repo on GitHub if it doesn't exist","entry_type":"flag"},{"command":"f sync","short":"-f","long":"--fix","description":"Auto-fix conflicts and errors using Claude (default: true)","entry_type":"flag"},{"command":"f sync","short":null,"long":"--no-fix","description":"Disable auto-fix (same as --fix=false)","entry_type":"flag"},{"command":"f sync","short":null,"long":"--max-fix-attempts","description":"Maximum fix attempts before giving up","entry_type":"flag"},{"command":"f sync","short":null,"long":"--allow-review-issues","description":"Allow push even if P1/P2 review todos are open","entry_type":"flag"},{"command":"f sync","short":null,"long":"--compact","description":"Reduce sync output noise (show remote update counts without commit line listings)","entry_type":"flag"},{"command":"f checkout","short":null,"long":null,"description":"Checkout a GitHub PR safely.","entry_type":"subcommand"},{"command":"f checkout","short":null,"long":"--remote","description":"Preferred remote to use when checking out a PR branch","entry_type":"flag"},{"command":"f checkout","short":null,"long":"--stash","description":"Auto-stash uncommitted changes before checkout (default: true)","entry_type":"flag"},{"command":"f checkout","short":null,"long":"--no-stash","description":"Disable auto-stash (same as --stash=false)","entry_type":"flag"},{"command":"f switch","short":null,"long":null,"description":"Switch to a branch and align upstream tracking.","entry_type":"subcommand"},{"command":"f switch","short":null,"long":"--remote","description":"Preferred remote to track from (default: upstream, then origin)","entry_type":"flag"},{"command":"f switch","short":null,"long":"--preserve","description":"Auto-preserve a safety snapshot branch/bookmark before switching (default: true)","entry_type":"flag"},{"command":"f switch","short":null,"long":"--no-preserve","description":"Disable safety snapshot preservation (same as --preserve=false)","entry_type":"flag"},{"command":"f switch","short":null,"long":"--stash","description":"Auto-stash uncommitted changes before switching (default: true)","entry_type":"flag"},{"command":"f switch","short":null,"long":"--no-stash","description":"Disable auto-stash (same as --stash=false)","entry_type":"flag"},{"command":"f switch","short":null,"long":"--sync","description":"Run sync after switching (uses --no-push)","entry_type":"flag"},{"command":"f push","short":null,"long":null,"description":"Push current branch to a configured private mirror remote.","entry_type":"subcommand"},{"command":"f push","short":null,"long":"--remote","description":"Git remote name to push to (default: origin)","entry_type":"flag"},{"command":"f push","short":null,"long":"--owner","description":"Owner/org for the mirror repo (overrides FLOW_PUSH_OWNER / personal env store)","entry_type":"flag"},{"command":"f push","short":null,"long":"--repo","description":"Override repo name (defaults to upstream/origin repo name or folder name)","entry_type":"flag"},{"command":"f push","short":null,"long":"--create-repo","description":"Create the target repo if it does not exist (requires `gh` auth)","entry_type":"flag"},{"command":"f push","short":null,"long":"--force","description":"Overwrite an existing remote URL when it points elsewhere","entry_type":"flag"},{"command":"f push","short":null,"long":"--no-ssh","description":"Do not attempt to unlock Flow-managed SSH key before pushing","entry_type":"flag"},{"command":"f push","short":null,"long":"--ttl-hours","description":"TTL (hours) for Flow SSH key unlock (default: 24)","entry_type":"flag"},{"command":"f push","short":null,"long":"--dry-run","description":"Print what would be done without changing remotes or pushing","entry_type":"flag"},{"command":"f status","short":null,"long":null,"description":"Show JJ workflow status optimized for stacked home-branch work.","entry_type":"subcommand"},{"command":"f status","short":null,"long":"--raw","description":"Show raw `jj status` output without Flow's workflow summary","entry_type":"flag"},{"command":"f jj","short":null,"long":null,"description":"Jujutsu (jj) workflow helpers.","entry_type":"subcommand"},{"command":"f jj init","short":null,"long":null,"description":"Initialize jj in the repo (colocated with git when possible)","entry_type":"subcommand"},{"command":"f jj init","short":null,"long":"--path","description":"Optional path to initialize (defaults to current directory)","entry_type":"flag"},{"command":"f jj status","short":null,"long":null,"description":"Show jj status","entry_type":"subcommand"},{"command":"f jj status","short":null,"long":"--raw","description":"Show raw `jj status` output without Flow's workflow summary","entry_type":"flag"},{"command":"f jj fetch","short":null,"long":null,"description":"Fetch from git remotes","entry_type":"subcommand"},{"command":"f jj rebase","short":null,"long":null,"description":"Rebase current change onto a destination","entry_type":"subcommand"},{"command":"f jj rebase","short":null,"long":"--dest","description":"Destination to rebase onto (default: jj.default_branch or main/master)","entry_type":"flag"},{"command":"f jj push","short":null,"long":null,"description":"Push bookmarks to git","entry_type":"subcommand"},{"command":"f jj push","short":null,"long":"--bookmark","description":"Bookmark to push","entry_type":"flag"},{"command":"f jj push","short":null,"long":"--all","description":"Push all bookmarks","entry_type":"flag"},{"command":"f jj sync","short":null,"long":null,"description":"Fetch, rebase, then push a bookmark","entry_type":"subcommand"},{"command":"f jj sync","short":null,"long":"--bookmark","description":"Bookmark to push after rebase (optional)","entry_type":"flag"},{"command":"f jj sync","short":null,"long":"--dest","description":"Destination to rebase onto (default: jj.default_branch or main/master)","entry_type":"flag"},{"command":"f jj sync","short":null,"long":"--remote","description":"Remote to sync with (default: git.remote, then jj.remote, then origin)","entry_type":"flag"},{"command":"f jj sync","short":null,"long":"--no-push","description":"Skip pushing after rebase","entry_type":"flag"},{"command":"f jj workspace","short":null,"long":null,"description":"Manage workspaces","entry_type":"subcommand"},{"command":"f jj workspace list","short":null,"long":null,"description":"List workspaces","entry_type":"subcommand"},{"command":"f jj workspace add","short":null,"long":null,"description":"Add a workspace","entry_type":"subcommand"},{"command":"f jj workspace add","short":null,"long":"--path","description":"Optional path for workspace directory","entry_type":"flag"},{"command":"f jj workspace add","short":null,"long":"--rev","description":"Optional base revision for the new workspace working copy","entry_type":"flag"},{"command":"f jj workspace lane","short":null,"long":null,"description":"Create an isolated parallel workspace lane anchored on trunk","entry_type":"subcommand"},{"command":"f jj workspace lane","short":null,"long":"--path","description":"Optional path for workspace directory","entry_type":"flag"},{"command":"f jj workspace lane","short":null,"long":"--base","description":"Base revision (default: <default_branch>@<remote> if tracked, else <default_branch>)","entry_type":"flag"},{"command":"f jj workspace lane","short":null,"long":"--remote","description":"Remote used for default base resolution","entry_type":"flag"},{"command":"f jj workspace lane","short":null,"long":"--no-fetch","description":"Skip fetch before creating the lane","entry_type":"flag"},{"command":"f jj workspace review","short":null,"long":null,"description":"Create or reuse a stable JJ workspace for a review branch without touching the current checkout","entry_type":"subcommand"},{"command":"f jj workspace review","short":null,"long":"--path","description":"Optional path for workspace directory","entry_type":"flag"},{"command":"f jj workspace review","short":null,"long":"--base","description":"Optional base revision. Defaults to the branch commit when found, else trunk","entry_type":"flag"},{"command":"f jj workspace review","short":null,"long":"--remote","description":"Remote used for branch lookup and default base resolution","entry_type":"flag"},{"command":"f jj workspace review","short":null,"long":"--no-fetch","description":"Skip fetch before resolving the review branch","entry_type":"flag"},{"command":"f jj bookmark","short":null,"long":null,"description":"Manage bookmarks","entry_type":"subcommand"},{"command":"f jj bookmark list","short":null,"long":null,"description":"List bookmarks","entry_type":"subcommand"},{"command":"f jj bookmark track","short":null,"long":null,"description":"Track a bookmark from a remote","entry_type":"subcommand"},{"command":"f jj bookmark track","short":null,"long":"--remote","description":"Remote name (default: git.remote, then jj.remote, then origin)","entry_type":"flag"},{"command":"f jj bookmark create","short":null,"long":null,"description":"Create a bookmark at a revision","entry_type":"subcommand"},{"command":"f jj bookmark create","short":null,"long":"--rev","description":"Revision to attach to (default: @)","entry_type":"flag"},{"command":"f jj bookmark create","short":null,"long":"--track","description":"Whether to track the remote bookmark (default: jj.auto_track)","entry_type":"flag"},{"command":"f jj bookmark create","short":null,"long":"--remote","description":"Remote to track (default: git.remote, then jj.remote, then origin)","entry_type":"flag"},{"command":"f git-repair","short":null,"long":null,"description":"Repair git state (abort rebase/merge, leave detached HEAD).","entry_type":"subcommand"},{"command":"f git-repair","short":null,"long":"--branch","description":"Branch to checkout if HEAD is detached (default: main)","entry_type":"flag"},{"command":"f git-repair","short":"-n","long":"--dry-run","description":"Dry run - show what would be repaired","entry_type":"flag"},{"command":"f git-repair","short":null,"long":"--land-main","description":"After repair, switch to target branch and cherry-pick current HEAD onto it. If conflicts occur, flow auto-aborts and returns to the source branch","entry_type":"flag"},{"command":"f info","short":null,"long":null,"description":"Show project information.","entry_type":"subcommand"},{"command":"f upstream","short":null,"long":null,"description":"Manage upstream fork workflow.","entry_type":"subcommand"},{"command":"f upstream status","short":null,"long":null,"description":"Show current upstream configuration","entry_type":"subcommand"},{"command":"f upstream setup","short":null,"long":null,"description":"Set up upstream remote and local tracking branch","entry_type":"subcommand"},{"command":"f upstream setup","short":"-u","long":"--upstream-url","description":"URL of the upstream repository","entry_type":"flag"},{"command":"f upstream setup","short":"-u","long":"--upstream-branch","description":"Branch name on upstream (default: main)","entry_type":"flag"},{"command":"f upstream pull","short":null,"long":null,"description":"Pull changes from upstream into local 'upstream' branch","entry_type":"subcommand"},{"command":"f upstream pull","short":"-b","long":"--branch","description":"Also merge into this branch after pulling","entry_type":"flag"},{"command":"f upstream check","short":null,"long":null,"description":"Checkout local 'upstream' branch synced to upstream","entry_type":"subcommand"},{"command":"f upstream sync","short":null,"long":null,"description":"Full sync: pull upstream, merge to dev/main, push to origin","entry_type":"subcommand"},{"command":"f upstream sync","short":null,"long":"--no-push","description":"Skip pushing to origin","entry_type":"flag"},{"command":"f upstream sync","short":null,"long":"--create-repo","description":"Create origin repo on GitHub if it doesn't exist","entry_type":"flag"},{"command":"f upstream open","short":null,"long":null,"description":"Open upstream repository URL in browser","entry_type":"subcommand"},{"command":"f deploy","short":null,"long":null,"description":"Deploy project to host or cloud platform.","entry_type":"subcommand"},{"command":"f deploy host","short":null,"long":null,"description":"Deploy to Linux host via SSH","entry_type":"subcommand"},{"command":"f deploy host","short":null,"long":"--remote-build","description":"Build remotely instead of syncing local build artifacts","entry_type":"flag"},{"command":"f deploy host","short":null,"long":"--setup","description":"Run setup script even if already deployed","entry_type":"flag"},{"command":"f deploy cloudflare","short":null,"long":null,"description":"Deploy to Cloudflare Workers","entry_type":"subcommand"},{"command":"f deploy cloudflare","short":null,"long":"--secrets","description":"Also set secrets from env_file","entry_type":"flag"},{"command":"f deploy cloudflare","short":null,"long":"--dev","description":"Run in dev mode instead of deploying","entry_type":"flag"},{"command":"f deploy web","short":null,"long":null,"description":"Deploy the web site (Cloudflare)","entry_type":"subcommand"},{"command":"f deploy setup","short":null,"long":null,"description":"Interactive deploy setup (Cloudflare Workers for now)","entry_type":"subcommand"},{"command":"f deploy railway","short":null,"long":null,"description":"Deploy to Railway","entry_type":"subcommand"},{"command":"f deploy config","short":null,"long":null,"description":"Configure deployment defaults (Linux host)","entry_type":"subcommand"},{"command":"f deploy release","short":null,"long":null,"description":"Run the project's release task","entry_type":"subcommand"},{"command":"f deploy release","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f deploy status","short":null,"long":null,"description":"Show deployment status","entry_type":"subcommand"},{"command":"f deploy logs","short":null,"long":null,"description":"View deployment logs","entry_type":"subcommand"},{"command":"f deploy logs","short":"-f","long":"--follow","description":"Follow logs in real-time","entry_type":"flag"},{"command":"f deploy logs","short":null,"long":"--since-deploy","description":"Show logs since the last successful deploy (default)","entry_type":"flag"},{"command":"f deploy logs","short":null,"long":"--all","description":"Show full log history (ignores --since-deploy)","entry_type":"flag"},{"command":"f deploy logs","short":"-n","long":"--lines","description":"Number of lines to show","entry_type":"flag"},{"command":"f deploy restart","short":null,"long":null,"description":"Restart the deployed service","entry_type":"subcommand"},{"command":"f deploy stop","short":null,"long":null,"description":"Stop the deployed service","entry_type":"subcommand"},{"command":"f deploy shell","short":null,"long":null,"description":"SSH into the host (for host deployments)","entry_type":"subcommand"},{"command":"f deploy set-host","short":null,"long":null,"description":"Configure host for deployment","entry_type":"subcommand"},{"command":"f deploy show-host","short":null,"long":null,"description":"Show current host configuration","entry_type":"subcommand"},{"command":"f deploy health","short":null,"long":null,"description":"Check if deployment is healthy (HTTP health check)","entry_type":"subcommand"},{"command":"f deploy health","short":null,"long":"--url","description":"Custom URL to check (defaults to domain from config)","entry_type":"flag"},{"command":"f deploy health","short":null,"long":"--status","description":"Expected HTTP status code","entry_type":"flag"},{"command":"f prod","short":null,"long":null,"description":"Deploy to production using flow.toml deploy config.","entry_type":"subcommand"},{"command":"f prod host","short":null,"long":null,"description":"Deploy to Linux host via SSH","entry_type":"subcommand"},{"command":"f prod host","short":null,"long":"--remote-build","description":"Build remotely instead of syncing local build artifacts","entry_type":"flag"},{"command":"f prod host","short":null,"long":"--setup","description":"Run setup script even if already deployed","entry_type":"flag"},{"command":"f prod cloudflare","short":null,"long":null,"description":"Deploy to Cloudflare Workers","entry_type":"subcommand"},{"command":"f prod cloudflare","short":null,"long":"--secrets","description":"Also set secrets from env_file","entry_type":"flag"},{"command":"f prod cloudflare","short":null,"long":"--dev","description":"Run in dev mode instead of deploying","entry_type":"flag"},{"command":"f prod web","short":null,"long":null,"description":"Deploy the web site (Cloudflare)","entry_type":"subcommand"},{"command":"f prod setup","short":null,"long":null,"description":"Interactive deploy setup (Cloudflare Workers for now)","entry_type":"subcommand"},{"command":"f prod railway","short":null,"long":null,"description":"Deploy to Railway","entry_type":"subcommand"},{"command":"f prod config","short":null,"long":null,"description":"Configure deployment defaults (Linux host)","entry_type":"subcommand"},{"command":"f prod release","short":null,"long":null,"description":"Run the project's release task","entry_type":"subcommand"},{"command":"f prod release","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f prod status","short":null,"long":null,"description":"Show deployment status","entry_type":"subcommand"},{"command":"f prod logs","short":null,"long":null,"description":"View deployment logs","entry_type":"subcommand"},{"command":"f prod logs","short":"-f","long":"--follow","description":"Follow logs in real-time","entry_type":"flag"},{"command":"f prod logs","short":null,"long":"--since-deploy","description":"Show logs since the last successful deploy (default)","entry_type":"flag"},{"command":"f prod logs","short":null,"long":"--all","description":"Show full log history (ignores --since-deploy)","entry_type":"flag"},{"command":"f prod logs","short":"-n","long":"--lines","description":"Number of lines to show","entry_type":"flag"},{"command":"f prod restart","short":null,"long":null,"description":"Restart the deployed service","entry_type":"subcommand"},{"command":"f prod stop","short":null,"long":null,"description":"Stop the deployed service","entry_type":"subcommand"},{"command":"f prod shell","short":null,"long":null,"description":"SSH into the host (for host deployments)","entry_type":"subcommand"},{"command":"f prod set-host","short":null,"long":null,"description":"Configure host for deployment","entry_type":"subcommand"},{"command":"f prod show-host","short":null,"long":null,"description":"Show current host configuration","entry_type":"subcommand"},{"command":"f prod health","short":null,"long":null,"description":"Check if deployment is healthy (HTTP health check)","entry_type":"subcommand"},{"command":"f prod health","short":null,"long":"--url","description":"Custom URL to check (defaults to domain from config)","entry_type":"flag"},{"command":"f prod health","short":null,"long":"--status","description":"Expected HTTP status code","entry_type":"flag"},{"command":"f publish","short":null,"long":null,"description":"Publish project to gitedit.dev or GitHub.","entry_type":"subcommand"},{"command":"f publish gitedit","short":null,"long":null,"description":"Publish to gitedit.dev","entry_type":"subcommand"},{"command":"f publish gitedit","short":"-n","long":"--name","description":"Repository name (defaults to current folder name)","entry_type":"flag"},{"command":"f publish gitedit","short":null,"long":"--owner","description":"Repository owner/org (GitHub) or owner (gitedit.dev)","entry_type":"flag"},{"command":"f publish gitedit","short":null,"long":"--set-origin","description":"Update existing origin remote to match the target repo (GitHub)","entry_type":"flag"},{"command":"f publish gitedit","short":null,"long":"--public","description":"Make the repository public","entry_type":"flag"},{"command":"f publish gitedit","short":null,"long":"--private","description":"Make the repository private","entry_type":"flag"},{"command":"f publish gitedit","short":"-d","long":"--description","description":"Description for the repository","entry_type":"flag"},{"command":"f publish gitedit","short":"-y","long":"--yes","description":"Skip confirmation prompts","entry_type":"flag"},{"command":"f publish github","short":null,"long":null,"description":"Publish to GitHub","entry_type":"subcommand"},{"command":"f publish github","short":"-n","long":"--name","description":"Repository name (defaults to current folder name)","entry_type":"flag"},{"command":"f publish github","short":null,"long":"--owner","description":"Repository owner/org (GitHub) or owner (gitedit.dev)","entry_type":"flag"},{"command":"f publish github","short":null,"long":"--set-origin","description":"Update existing origin remote to match the target repo (GitHub)","entry_type":"flag"},{"command":"f publish github","short":null,"long":"--public","description":"Make the repository public","entry_type":"flag"},{"command":"f publish github","short":null,"long":"--private","description":"Make the repository private","entry_type":"flag"},{"command":"f publish github","short":"-d","long":"--description","description":"Description for the repository","entry_type":"flag"},{"command":"f publish github","short":"-y","long":"--yes","description":"Skip confirmation prompts","entry_type":"flag"},{"command":"f clone","short":null,"long":null,"description":"Clone a repository into the current directory (git clone style).","entry_type":"subcommand"},{"command":"f repos","short":null,"long":null,"description":"Clone repositories into a structured local directory.","entry_type":"subcommand"},{"command":"f repos clone","short":null,"long":null,"description":"Clone a repository into ~/repos/<owner>/<repo>","entry_type":"subcommand"},{"command":"f repos clone","short":null,"long":"--root","description":"Root directory for clones (default: ~/repos)","entry_type":"flag"},{"command":"f repos clone","short":null,"long":"--full","description":"Perform a full clone (skip shallow clone + background history fetch)","entry_type":"flag"},{"command":"f repos clone","short":null,"long":"--no-upstream","description":"Skip automatic upstream setup for forks","entry_type":"flag"},{"command":"f repos clone","short":"-u","long":"--upstream-url","description":"Upstream URL override (defaults to fork parent via gh)","entry_type":"flag"},{"command":"f repos create","short":null,"long":null,"description":"Create a GitHub repository from the current folder and push it","entry_type":"subcommand"},{"command":"f repos create","short":"-n","long":"--name","description":"Repository name (defaults to current folder name)","entry_type":"flag"},{"command":"f repos create","short":null,"long":"--owner","description":"Repository owner/org (GitHub) or owner (gitedit.dev)","entry_type":"flag"},{"command":"f repos create","short":null,"long":"--set-origin","description":"Update existing origin remote to match the target repo (GitHub)","entry_type":"flag"},{"command":"f repos create","short":null,"long":"--public","description":"Make the repository public","entry_type":"flag"},{"command":"f repos create","short":null,"long":"--private","description":"Make the repository private","entry_type":"flag"},{"command":"f repos create","short":"-d","long":"--description","description":"Description for the repository","entry_type":"flag"},{"command":"f repos create","short":"-y","long":"--yes","description":"Skip confirmation prompts","entry_type":"flag"},{"command":"f repos capsule","short":null,"long":null,"description":"Build or inspect a compact repo capsule for path-based Codex context","entry_type":"subcommand"},{"command":"f repos capsule","short":null,"long":"--path","description":"Repo or project path to inspect (defaults to the current directory)","entry_type":"flag"},{"command":"f repos capsule","short":null,"long":"--refresh","description":"Force a fresh capsule rebuild before printing","entry_type":"flag"},{"command":"f repos capsule","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f repos alias","short":null,"long":null,"description":"Manage repo aliases used by Codex repo-reference resolution","entry_type":"subcommand"},{"command":"f repos alias list","short":null,"long":null,"description":"List registered repo aliases","entry_type":"subcommand"},{"command":"f repos alias list","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f repos alias set","short":null,"long":null,"description":"Register or update an alias for a repo path","entry_type":"subcommand"},{"command":"f repos alias set","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f repos alias remove","short":null,"long":null,"description":"Remove a registered alias","entry_type":"subcommand"},{"command":"f repos alias import-shelf","short":null,"long":null,"description":"Import aliases from Shelf config","entry_type":"subcommand"},{"command":"f repos alias import-shelf","short":null,"long":"--config","description":"Override the Shelf config path (default: ~/.agents/shelf/config.json)","entry_type":"flag"},{"command":"f repos alias import-shelf","short":null,"long":"--json","description":"Emit machine-readable JSON","entry_type":"flag"},{"command":"f code","short":null,"long":null,"description":"Browse git repos under ~/code.","entry_type":"subcommand"},{"command":"f code","short":null,"long":"--root","description":"Root directory to scan (default: ~/code)","entry_type":"flag"},{"command":"f code list","short":null,"long":null,"description":"List git repos under ~/code","entry_type":"subcommand"},{"command":"f code new","short":null,"long":null,"description":"Create a new project from a template in ~/new/<name>","entry_type":"subcommand"},{"command":"f code new","short":null,"long":"--ignored","description":"Add the new path to .gitignore in the containing repo","entry_type":"flag"},{"command":"f code new","short":null,"long":"--dry-run","description":"Show what would change without writing","entry_type":"flag"},{"command":"f code migrate","short":null,"long":null,"description":"Move a folder into ~/code/<relative-path> and migrate AI sessions","entry_type":"subcommand"},{"command":"f code migrate","short":"-c","long":"--copy","description":"Copy instead of move (keeps original)","entry_type":"flag"},{"command":"f code migrate","short":null,"long":"--dry-run","description":"Show what would change without writing","entry_type":"flag"},{"command":"f code migrate","short":null,"long":"--skip-claude","description":"Skip migrating Claude sessions","entry_type":"flag"},{"command":"f code migrate","short":null,"long":"--skip-codex","description":"Skip migrating Codex sessions","entry_type":"flag"},{"command":"f code move-sessions","short":null,"long":null,"description":"Move AI sessions when a project path changes","entry_type":"subcommand"},{"command":"f code move-sessions","short":null,"long":"--from","description":"Old project path","entry_type":"flag"},{"command":"f code move-sessions","short":null,"long":"--to","description":"New project path","entry_type":"flag"},{"command":"f code move-sessions","short":null,"long":"--dry-run","description":"Show what would change without writing","entry_type":"flag"},{"command":"f code move-sessions","short":null,"long":"--skip-claude","description":"Skip migrating Claude sessions","entry_type":"flag"},{"command":"f code move-sessions","short":null,"long":"--skip-codex","description":"Skip migrating Codex sessions","entry_type":"flag"},{"command":"f migrate","short":null,"long":null,"description":"Move or copy a folder to a new location, preserving symlinks and AI sessions.","entry_type":"subcommand"},{"command":"f migrate","short":"-c","long":"--copy","description":"Copy instead of move (keeps original)","entry_type":"flag"},{"command":"f migrate","short":null,"long":"--dry-run","description":"Show what would change without writing","entry_type":"flag"},{"command":"f migrate","short":null,"long":"--skip-claude","description":"Skip migrating Claude sessions","entry_type":"flag"},{"command":"f migrate","short":null,"long":"--skip-codex","description":"Skip migrating Codex sessions","entry_type":"flag"},{"command":"f migrate code","short":null,"long":null,"description":"Move or copy current folder to ~/code/<relative-path>","entry_type":"subcommand"},{"command":"f migrate code","short":"-c","long":"--copy","description":"Copy instead of move (keeps original)","entry_type":"flag"},{"command":"f migrate code","short":null,"long":"--dry-run","description":"Show what would change without writing","entry_type":"flag"},{"command":"f migrate code","short":null,"long":"--skip-claude","description":"Skip migrating Claude sessions","entry_type":"flag"},{"command":"f migrate code","short":null,"long":"--skip-codex","description":"Skip migrating Codex sessions","entry_type":"flag"},{"command":"f parallel","short":null,"long":null,"description":"Run tasks in parallel with pretty status display.","entry_type":"subcommand"},{"command":"f parallel","short":"-j","long":"--jobs","description":"Maximum number of concurrent jobs (default: number of CPU cores)","entry_type":"flag"},{"command":"f parallel","short":"-f","long":"--fail-fast","description":"Stop all tasks on first failure","entry_type":"flag"},{"command":"f docs","short":null,"long":null,"description":"Manage auto-generated documentation in .ai/docs/.","entry_type":"subcommand"},{"command":"f docs new","short":null,"long":null,"description":"Create a docs/ folder with starter markdown files","entry_type":"subcommand"},{"command":"f docs new","short":null,"long":"--path","description":"Path to create docs in (defaults to current directory)","entry_type":"flag"},{"command":"f docs new","short":null,"long":"--force","description":"Overwrite if docs/ already exists","entry_type":"flag"},{"command":"f docs hub","short":null,"long":null,"description":"Run the docs hub that aggregates docs from ~/code and ~/org","entry_type":"subcommand"},{"command":"f docs hub","short":null,"long":"--host","description":"Host to bind the docs hub to","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--port","description":"Port for the docs hub","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--hub-root","description":"Docs hub root (defaults to ~/.config/flow/docs-hub)","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--template-root","description":"Template root (defaults to ~/new/docs)","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--code-root","description":"Code root to scan for docs (defaults to ~/code)","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--org-root","description":"Org root to scan for docs (defaults to ~/org)","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--no-ai","description":"Skip scanning for .ai/docs","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--no-open","description":"Skip opening the browser","entry_type":"flag"},{"command":"f docs hub","short":null,"long":"--sync-only","description":"Sync content and exit without running the dev server","entry_type":"flag"},{"command":"f docs deploy","short":null,"long":null,"description":"Deploy the docs hub to Cloudflare Pages","entry_type":"subcommand"},{"command":"f docs deploy","short":null,"long":"--project","description":"Cloudflare Pages project name (defaults to flow.toml name)","entry_type":"flag"},{"command":"f docs deploy","short":null,"long":"--domain","description":"Custom domain to attach (optional)","entry_type":"flag"},{"command":"f docs deploy","short":"-y","long":"--yes","description":"Skip confirmation prompts","entry_type":"flag"},{"command":"f docs sync","short":null,"long":null,"description":"Sync documentation with recent commits","entry_type":"subcommand"},{"command":"f docs sync","short":"-n","long":"--commits","description":"Number of commits to analyze (default: 10)","entry_type":"flag"},{"command":"f docs sync","short":null,"long":"--dry","description":"Dry run: show what would be updated without changing files","entry_type":"flag"},{"command":"f docs list","short":null,"long":null,"description":"List documentation files","entry_type":"subcommand"},{"command":"f docs status","short":null,"long":null,"description":"Show documentation status (what needs updating)","entry_type":"subcommand"},{"command":"f docs edit","short":null,"long":null,"description":"Open a doc file in editor","entry_type":"subcommand"},{"command":"f upgrade","short":null,"long":null,"description":"Upgrade flow to the latest version.","entry_type":"subcommand"},{"command":"f upgrade","short":null,"long":"--canary","description":"Upgrade to the latest canary build (GitHub release tag: \"canary\")","entry_type":"flag"},{"command":"f upgrade","short":null,"long":"--stable","description":"Upgrade to the latest stable release (GitHub \"latest\" release)","entry_type":"flag"},{"command":"f upgrade","short":"-n","long":"--dry-run","description":"Print what would happen without making changes","entry_type":"flag"},{"command":"f upgrade","short":"-f","long":"--force","description":"Force upgrade even if already on the latest version","entry_type":"flag"},{"command":"f upgrade","short":"-o","long":"--output","description":"Download to a specific path instead of replacing the current executable","entry_type":"flag"},{"command":"f latest","short":null,"long":null,"description":"Pull ~/code/flow and rebuild the local flow binary.","entry_type":"subcommand"},{"command":"f release","short":null,"long":null,"description":"Release a project (registry, GitHub, or task).","entry_type":"subcommand"},{"command":"f release","short":null,"long":"--config","description":"Path to the project flow config (flow.toml)","entry_type":"flag"},{"command":"f release task","short":null,"long":null,"description":"Run the configured release task","entry_type":"subcommand"},{"command":"f release registry","short":null,"long":null,"description":"Publish a release to a Flow registry","entry_type":"subcommand"},{"command":"f release registry","short":"-v","long":"--version","description":"Version to publish (auto-detected if omitted)","entry_type":"flag"},{"command":"f release registry","short":null,"long":"--registry","description":"Registry base URL (overrides flow.toml)","entry_type":"flag"},{"command":"f release registry","short":null,"long":"--package","description":"Override package name for the registry","entry_type":"flag"},{"command":"f release registry","short":null,"long":"--bin","description":"Override the binary name(s) to upload","entry_type":"flag"},{"command":"f release registry","short":null,"long":"--no-build","description":"Skip building binaries before publishing","entry_type":"flag"},{"command":"f release registry","short":null,"long":"--latest","description":"Mark this version as latest in the registry","entry_type":"flag"},{"command":"f release registry","short":null,"long":"--no-latest","description":"Skip updating the latest pointer","entry_type":"flag"},{"command":"f release registry","short":"-n","long":"--dry-run","description":"Dry run: show what would be published without publishing","entry_type":"flag"},{"command":"f release github","short":null,"long":null,"description":"Manage GitHub releases","entry_type":"subcommand"},{"command":"f release github create","short":null,"long":null,"description":"Create a new GitHub release","entry_type":"subcommand"},{"command":"f release github create","short":"-t","long":"--title","description":"Release title (defaults to tag name)","entry_type":"flag"},{"command":"f release github create","short":"-n","long":"--notes","description":"Release notes (reads from stdin or file if not provided)","entry_type":"flag"},{"command":"f release github create","short":null,"long":"--notes-file","description":"Read release notes from a file","entry_type":"flag"},{"command":"f release github create","short":null,"long":"--generate-notes","description":"Generate release notes automatically from commits","entry_type":"flag"},{"command":"f release github create","short":null,"long":"--draft","description":"Create as draft release","entry_type":"flag"},{"command":"f release github create","short":null,"long":"--prerelease","description":"Mark as prerelease","entry_type":"flag"},{"command":"f release github create","short":"-a","long":"--asset","description":"Asset files to upload (can be specified multiple times)","entry_type":"flag"},{"command":"f release github create","short":null,"long":"--target","description":"Target commit/branch for the release tag","entry_type":"flag"},{"command":"f release github create","short":"-y","long":"--yes","description":"Skip confirmation prompts","entry_type":"flag"},{"command":"f release github list","short":null,"long":null,"description":"List recent releases","entry_type":"subcommand"},{"command":"f release github list","short":"-l","long":"--limit","description":"Number of releases to show","entry_type":"flag"},{"command":"f release github delete","short":null,"long":null,"description":"Delete a release","entry_type":"subcommand"},{"command":"f release github delete","short":"-y","long":"--yes","description":"Skip confirmation","entry_type":"flag"},{"command":"f release github download","short":null,"long":null,"description":"Download release assets","entry_type":"subcommand"},{"command":"f release github download","short":"-t","long":"--tag","description":"Release tag (defaults to latest)","entry_type":"flag"},{"command":"f release github download","short":"-o","long":"--output","description":"Output directory","entry_type":"flag"},{"command":"f release signing","short":null,"long":null,"description":"Manage macOS code signing and GitHub Actions secrets for releases","entry_type":"subcommand"},{"command":"f release signing status","short":null,"long":null,"description":"Show current signing setup status (Keychain + Flow env store)","entry_type":"subcommand"},{"command":"f release signing store","short":null,"long":null,"description":"Store signing secrets into Flow personal env store","entry_type":"subcommand"},{"command":"f release signing store","short":null,"long":"--p12","description":"Path to exported .p12 file (Developer ID Application certificate + key)","entry_type":"flag"},{"command":"f release signing store","short":null,"long":"--p12-password","description":"Password for the .p12 (must match what the release workflow imports with)","entry_type":"flag"},{"command":"f release signing store","short":null,"long":"--identity","description":"Signing identity passed to `codesign` (e.g. \"Developer ID Application: ... (TEAMID)\")","entry_type":"flag"},{"command":"f release signing store","short":null,"long":"--dry-run","description":"Dry run: show what would be stored without writing to env store","entry_type":"flag"},{"command":"f release signing sync","short":null,"long":null,"description":"Sync signing secrets from Flow env store into GitHub Actions secrets","entry_type":"subcommand"},{"command":"f release signing sync","short":null,"long":"--repo","description":"GitHub repo in \"owner/repo\" form (defaults to repo inferred from current directory)","entry_type":"flag"},{"command":"f release signing sync","short":null,"long":"--dry-run","description":"Dry run: show what would be synced without calling `gh`","entry_type":"flag"},{"command":"f install","short":null,"long":null,"description":"Install a CLI/tool binary (registry, parm, or flox).","entry_type":"subcommand"},{"command":"f install","short":null,"long":"--registry","description":"Registry base URL (defaults to FLOW_REGISTRY_URL)","entry_type":"flag"},{"command":"f install","short":null,"long":"--backend","description":"Install backend (auto tries registry, then parm, then flox)","entry_type":"flag"},{"command":"f install","short":"-v","long":"--version","description":"Version to install (defaults to latest)","entry_type":"flag"},{"command":"f install","short":null,"long":"--bin","description":"Binary name to install (defaults to the package name or manifest default)","entry_type":"flag"},{"command":"f install","short":null,"long":"--bin-dir","description":"Install directory (defaults to ~/bin)","entry_type":"flag"},{"command":"f install","short":null,"long":"--no-verify","description":"Skip checksum verification","entry_type":"flag"},{"command":"f install","short":null,"long":"--force","description":"Overwrite existing binary if present","entry_type":"flag"},{"command":"f install index","short":null,"long":null,"description":"Index flox packages into Typesense","entry_type":"subcommand"},{"command":"f install index","short":null,"long":"--queries","description":"File with newline-separated search terms","entry_type":"flag"},{"command":"f install index","short":null,"long":"--url","description":"Typesense base URL (overrides FLOW_TYPESENSE_URL)","entry_type":"flag"},{"command":"f install index","short":null,"long":"--api-key","description":"Typesense API key (overrides FLOW_TYPESENSE_API_KEY)","entry_type":"flag"},{"command":"f install index","short":null,"long":"--collection","description":"Typesense collection name (overrides FLOW_TYPESENSE_COLLECTION)","entry_type":"flag"},{"command":"f install index","short":null,"long":"--server","description":"Index server URL (defaults to local base server)","entry_type":"flag"},{"command":"f install index","short":null,"long":"--direct","description":"Skip index server and write directly to Typesense","entry_type":"flag"},{"command":"f install index","short":null,"long":"--per-page","description":"Max results per search term","entry_type":"flag"},{"command":"f install index","short":"-n","long":"--dry-run","description":"Dry run (do not write to Typesense)","entry_type":"flag"},{"command":"f registry","short":null,"long":null,"description":"Manage the Flow registry (tokens, setup).","entry_type":"subcommand"},{"command":"f registry init","short":null,"long":null,"description":"Create a registry token and configure worker + env","entry_type":"subcommand"},{"command":"f registry init","short":"-w","long":"--worker","description":"Path to the worker project (defaults to packages/worker)","entry_type":"flag"},{"command":"f registry init","short":null,"long":"--registry","description":"Registry base URL (overrides flow.toml or FLOW_REGISTRY_URL)","entry_type":"flag"},{"command":"f registry init","short":null,"long":"--token-env","description":"Env var name for the registry token","entry_type":"flag"},{"command":"f registry init","short":null,"long":"--token","description":"Provide an explicit token instead of generating one","entry_type":"flag"},{"command":"f registry init","short":null,"long":"--no-worker","description":"Skip updating the worker secret","entry_type":"flag"},{"command":"f registry init","short":null,"long":"--show-token","description":"Print the generated token to stdout","entry_type":"flag"},{"command":"f proxy","short":null,"long":null,"description":"Zero-cost traced reverse proxy for development.","entry_type":"subcommand"},{"command":"f proxy start","short":null,"long":null,"description":"Start the proxy server (reads [[proxies]] from flow.toml)","entry_type":"subcommand"},{"command":"f proxy start","short":"-l","long":"--listen","description":"Listen address (e.g., \":8080\" or \"127.0.0.1:8080\")","entry_type":"flag"},{"command":"f proxy start","short":"-f","long":"--foreground","description":"Run in foreground (don't daemonize)","entry_type":"flag"},{"command":"f proxy trace","short":null,"long":null,"description":"View recent request traces","entry_type":"subcommand"},{"command":"f proxy trace","short":"-n","long":"--count","description":"Number of records to show","entry_type":"flag"},{"command":"f proxy trace","short":"-f","long":"--follow","description":"Follow trace in real-time","entry_type":"flag"},{"command":"f proxy trace","short":null,"long":"--target","description":"Filter by target name","entry_type":"flag"},{"command":"f proxy trace","short":null,"long":"--errors","description":"Show only errors (status >= 400)","entry_type":"flag"},{"command":"f proxy trace","short":null,"long":"--id","description":"Filter by trace ID","entry_type":"flag"},{"command":"f proxy last","short":null,"long":null,"description":"Show the last request details","entry_type":"subcommand"},{"command":"f proxy last","short":null,"long":"--errors","description":"Show only errors","entry_type":"flag"},{"command":"f proxy last","short":null,"long":"--target","description":"Filter by target name","entry_type":"flag"},{"command":"f proxy last","short":null,"long":"--body","description":"Include request/response body","entry_type":"flag"},{"command":"f proxy add","short":null,"long":null,"description":"Add a new proxy target","entry_type":"subcommand"},{"command":"f proxy add","short":"-n","long":"--name","description":"Proxy name (auto-suggested if not provided)","entry_type":"flag"},{"command":"f proxy add","short":null,"long":"--host","description":"Host-based routing","entry_type":"flag"},{"command":"f proxy add","short":null,"long":"--path","description":"Path prefix routing","entry_type":"flag"},{"command":"f proxy list","short":null,"long":null,"description":"List configured proxy targets","entry_type":"subcommand"},{"command":"f proxy stop","short":null,"long":null,"description":"Stop the proxy server","entry_type":"subcommand"},{"command":"f domains","short":null,"long":null,"description":"Manage shared local *.localhost routing on port 80.","entry_type":"subcommand"},{"command":"f domains","short":null,"long":"--engine","description":"Routing engine to use (`docker` default, or `native` for experimental C++ daemon)","entry_type":"flag"},{"command":"f domains up","short":null,"long":null,"description":"Start the shared local-domain proxy on port 80","entry_type":"subcommand"},{"command":"f domains down","short":null,"long":null,"description":"Stop the shared local-domain proxy","entry_type":"subcommand"},{"command":"f domains list","short":null,"long":null,"description":"List configured host -> target routes","entry_type":"subcommand"},{"command":"f domains get","short":null,"long":null,"description":"Print the public URL for a configured localhost route","entry_type":"subcommand"},{"command":"f domains get","short":null,"long":"--target","description":"Print the upstream host:port instead of the public URL","entry_type":"flag"},{"command":"f domains add","short":null,"long":null,"description":"Add a localhost route (for example: linsa.localhost -> 127.0.0.1:3481)","entry_type":"subcommand"},{"command":"f domains add","short":null,"long":"--replace","description":"Replace existing route target for this host","entry_type":"flag"},{"command":"f domains rm","short":null,"long":null,"description":"Remove a localhost route","entry_type":"subcommand"},{"command":"f domains doctor","short":null,"long":null,"description":"Show proxy ownership, port 80 conflicts, and route summary","entry_type":"subcommand"},{"command":"f","short":null,"long":"--help-full","description":"Output all commands in machine-readable JSON format for external tools","entry_type":"flag"}]} ================================================ FILE: src/help_search.rs ================================================ //! Fuzzy search through all Flow CLI commands. use anyhow::{Context, Result}; use clap::{Command, CommandFactory}; use serde::Serialize; use std::io::{BufWriter, Write}; use std::process::{Command as Cmd, Stdio}; use crate::cli::Cli; const EMBEDDED_HELP_JSON: &str = include_str!("help_full.json"); const HELP_FULL_REGENERATE_ENV: &str = "FLOW_REGENERATE_HELP_FULL"; /// Entry format compatible with the `cmd` tool's cache format. #[derive(Serialize)] struct Entry { command: String, short: Option<String>, long: Option<String>, description: String, entry_type: String, } #[derive(Serialize)] struct CommandInfo { version: String, entries: Vec<Entry>, } /// Collect all commands recursively from clap's command tree. fn collect_commands(cmd: &Command, prefix: &str, entries: &mut Vec<(String, String)>) { let name = cmd.get_name(); let full_path = if prefix.is_empty() { name.to_string() } else { format!("{} {}", prefix, name) }; if let Some(about) = cmd.get_about() { entries.push((full_path.clone(), about.to_string())); } for sub in cmd.get_subcommands() { if !sub.is_hide_set() { collect_commands(sub, &full_path, entries); } } } /// Run fuzzy search over all Flow commands. pub fn run() -> Result<()> { let cmd = Cli::command(); let mut entries = Vec::new(); for sub in cmd.get_subcommands() { if !sub.is_hide_set() { collect_commands(sub, "f", &mut entries); } } // Format for fzf: command<tab>description let input = entries .iter() .map(|(cmd, desc)| format!("{}\t{}", cmd, desc)) .collect::<Vec<_>>() .join("\n"); let mut fzf = Cmd::new("fzf") .args([ "--height=50%", "--reverse", "--delimiter=\t", "--with-nth=1,2", "--preview-window=hidden", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf - is it installed?")?; fzf.stdin.as_mut().unwrap().write_all(input.as_bytes())?; let output = fzf.wait_with_output()?; if !output.status.success() { return Ok(()); // User cancelled } let selected = String::from_utf8_lossy(&output.stdout) .trim() .split('\t') .next() .unwrap_or("") .to_string(); if !selected.is_empty() { // Show help for selected command println!(); let parts: Vec<&str> = selected.split_whitespace().skip(1).collect(); let mut cmd = Cmd::new("f"); cmd.args(&parts); cmd.arg("--help"); cmd.status()?; } Ok(()) } /// Collect all commands and flags recursively in cmd-tool format. fn collect_entries(cmd: &Command, prefix: &str, entries: &mut Vec<Entry>) { let name = cmd.get_name(); let full_path = if prefix.is_empty() { name.to_string() } else { format!("{} {}", prefix, name) }; // Add the subcommand itself if let Some(about) = cmd.get_about() { entries.push(Entry { command: full_path.clone(), short: None, long: None, description: about.to_string(), entry_type: "subcommand".to_string(), }); } // Add flags/options for this command for arg in cmd.get_arguments() { if arg.is_hide_set() { continue; } let short = arg.get_short().map(|c| format!("-{}", c)); let long = arg.get_long().map(|s| format!("--{}", s)); // Skip if no flag representation if short.is_none() && long.is_none() { continue; } let description = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); entries.push(Entry { command: full_path.clone(), short, long, description, entry_type: "flag".to_string(), }); } // Recurse into subcommands for sub in cmd.get_subcommands() { if !sub.is_hide_set() { collect_entries(sub, &full_path, entries); } } } /// Output all commands in JSON format compatible with the `cmd` tool. pub fn print_full_json() -> Result<()> { let stdout = std::io::stdout(); let mut writer = BufWriter::new(stdout.lock()); if should_regenerate_help_full() { write_generated_full_json(&mut writer)?; } else { writer.write_all(EMBEDDED_HELP_JSON.as_bytes())?; if !EMBEDDED_HELP_JSON.ends_with('\n') { writer.write_all(b"\n")?; } } Ok(()) } fn should_regenerate_help_full() -> bool { matches!( std::env::var(HELP_FULL_REGENERATE_ENV) .ok() .as_deref() .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), Some("1" | "true" | "yes" | "on") ) } fn write_generated_full_json(writer: &mut impl Write) -> Result<()> { let cmd = Cli::command(); let mut entries = Vec::with_capacity(512); for sub in cmd.get_subcommands() { if !sub.is_hide_set() { collect_entries(sub, "f", &mut entries); } } for arg in cmd.get_arguments() { if arg.is_hide_set() { continue; } let short = arg.get_short().map(|c| format!("-{}", c)); let long = arg.get_long().map(|s| format!("--{}", s)); if short.is_none() && long.is_none() { continue; } let description = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); entries.push(Entry { command: "f".to_string(), short, long, description, entry_type: "flag".to_string(), }); } let version = env!("CARGO_PKG_VERSION").to_string(); let info = CommandInfo { version, entries }; serde_json::to_writer(&mut *writer, &info)?; writer.write_all(b"\n")?; Ok(()) } #[cfg(test)] mod tests { use super::{EMBEDDED_HELP_JSON, write_generated_full_json}; use anyhow::Result; #[test] fn embedded_help_json_matches_current_cli() -> Result<()> { let mut generated = Vec::new(); write_generated_full_json(&mut generated)?; assert_eq!( String::from_utf8(generated).expect("generated help JSON should be UTF-8"), EMBEDDED_HELP_JSON ); Ok(()) } } ================================================ FILE: src/history.rs ================================================ use std::{ collections::HashSet, fs::{File, OpenOptions}, io::{Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::config; use crate::secret_redact; const HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; #[derive(Serialize, Deserialize)] pub struct InvocationRecord { pub timestamp_ms: u128, pub duration_ms: u128, pub project_root: String, #[serde(default)] pub project_name: Option<String>, pub config_path: String, pub task_name: String, pub command: String, #[serde(default)] pub user_input: String, pub status: Option<i32>, pub success: bool, pub used_flox: bool, pub output: String, pub flow_version: String, } impl InvocationRecord { pub fn new( project_root: impl Into<String>, config_path: impl Into<String>, project_name: Option<&str>, task_name: impl Into<String>, command: impl Into<String>, user_input: impl Into<String>, used_flox: bool, ) -> Self { Self { timestamp_ms: now_ms(), duration_ms: 0, project_root: project_root.into(), project_name: project_name.map(|s| s.to_string()), config_path: config_path.into(), task_name: task_name.into(), command: command.into(), user_input: user_input.into(), status: None, success: false, used_flox, output: String::new(), flow_version: env!("CARGO_PKG_VERSION").to_string(), } } } pub fn record(invocation: InvocationRecord) -> Result<()> { let mut invocation = invocation; invocation.command = secret_redact::redact_text(&invocation.command); invocation.user_input = secret_redact::redact_text(&invocation.user_input); invocation.output = secret_redact::redact_text(&invocation.output); let path = history_path(); let _ = config::ensure_global_state_dir() .with_context(|| format!("failed to create history dir {}", path.display()))?; let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("failed to open history file {}", path.display()))?; let line = serde_json::to_string(&invocation).context("failed to serialize invocation")?; writeln!(file, "{line}").context("failed to write invocation to history")?; Ok(()) } /// Print the most recent invocation with only the user input and the resulting output or error. pub fn print_last_record() -> Result<()> { let path = history_path(); let record = load_last_record(&path)?; let Some(rec) = record else { if path.exists() { println!("No valid history entries found in {}", path.display()); } else { println!("No history found at {}", path.display()); } return Ok(()); }; let user_input = if rec.user_input.trim().is_empty() { rec.task_name.clone() } else { rec.user_input.clone() }; println!("{user_input}"); if rec.output.trim().is_empty() { if !rec.success { let status = rec .status .map(|s| s.to_string()) .unwrap_or_else(|| "unknown".to_string()); println!("error (status: {status})"); } } else { let output = secret_redact::redact_text(&rec.output); print!("{}", output); if !output.ends_with('\n') { println!(); } } Ok(()) } /// Print the most recent invocation with output and status. pub fn print_last_record_full() -> Result<()> { let path = history_path(); let record = load_last_record(&path)?; let Some(rec) = record else { if path.exists() { println!("No valid history entries found in {}", path.display()); } else { println!("No history found at {}", path.display()); } return Ok(()); }; println!("task: {}", rec.task_name); println!("command: {}", secret_redact::redact_text(&rec.command)); println!("project: {}", rec.project_root); if let Some(name) = rec.project_name.as_deref() { println!("project_name: {name}"); } println!("config: {}", rec.config_path); println!( "status: {} (code: {})", if rec.success { "success" } else { "failure" }, rec.status .map(|s| s.to_string()) .unwrap_or_else(|| "unknown".to_string()) ); println!("duration_ms: {}", rec.duration_ms); println!("flow_version: {}", rec.flow_version); println!("--- output ---"); print!("{}", secret_redact::redact_text(&rec.output)); Ok(()) } fn load_last_record(path: &Path) -> Result<Option<InvocationRecord>> { find_last_record_matching(path, |_| true) } /// Load the last invocation record for a specific project root. pub fn load_last_record_for_project(project_root: &Path) -> Result<Option<InvocationRecord>> { let path = history_path(); if !path.exists() { return Ok(None); } let canonical_root = project_root .canonicalize() .unwrap_or_else(|_| project_root.to_path_buf()); let canonical_str = canonical_root.to_string_lossy(); find_last_record_matching(&path, |rec| rec.project_root == canonical_str) } pub fn history_path() -> PathBuf { config::global_state_dir().join("history.jsonl") } /// Load unique task-history entries, most recent first, deduped by project + task name. pub fn load_unique_task_records() -> Result<Vec<InvocationRecord>> { let path = history_path(); if !path.exists() { return Ok(Vec::new()); } let mut seen: HashSet<(String, String)> = HashSet::new(); let mut records = Vec::new(); let _ = visit_lines_reverse(&path, |line| { if line.trim().is_empty() { return None::<()>; } let record = serde_json::from_str::<InvocationRecord>(line).ok()?; let key = (record.project_root.clone(), record.task_name.clone()); if seen.insert(key) { records.push(record); } None::<()> })?; Ok(records) } fn find_last_record_matching<F>(path: &Path, mut predicate: F) -> Result<Option<InvocationRecord>> where F: FnMut(&InvocationRecord) -> bool, { if !path.exists() { return Ok(None); } visit_lines_reverse(path, |line| { if line.trim().is_empty() { return None; } let record = serde_json::from_str::<InvocationRecord>(line).ok()?; if predicate(&record) { Some(record) } else { None } }) } fn visit_lines_reverse<T, F>(path: &Path, mut on_line: F) -> Result<Option<T>> where F: FnMut(&str) -> Option<T>, { let mut file = File::open(path) .with_context(|| format!("failed to read history at {}", path.display()))?; let mut pos = file.seek(SeekFrom::End(0))?; if pos == 0 { return Ok(None); } let mut chunk = vec![0u8; HISTORY_REVERSE_SCAN_CHUNK_BYTES]; let mut carry = Vec::new(); while pos > 0 { let read_len = usize::try_from(pos.min(chunk.len() as u64)).unwrap_or(chunk.len()); pos -= read_len as u64; file.seek(SeekFrom::Start(pos))?; file.read_exact(&mut chunk[..read_len]) .with_context(|| format!("failed to read history at {}", path.display()))?; let buf = &chunk[..read_len]; let mut end = read_len; while let Some(idx) = buf[..end].iter().rposition(|&byte| byte == b'\n') { if let Some(value) = process_reverse_line_segment(&buf[idx + 1..end], &mut carry, &mut on_line) { return Ok(Some(value)); } end = idx; } if end > 0 { let mut combined = Vec::with_capacity(end + carry.len()); combined.extend_from_slice(&buf[..end]); combined.extend_from_slice(&carry); carry = combined; } } if !carry.is_empty() && let Ok(line) = std::str::from_utf8(&carry) && let Some(value) = on_line(line.trim_end_matches('\r')) { return Ok(Some(value)); } Ok(None) } fn process_reverse_line_segment<T, F>( segment: &[u8], carry: &mut Vec<u8>, on_line: &mut F, ) -> Option<T> where F: FnMut(&str) -> Option<T>, { if carry.is_empty() { let line = std::str::from_utf8(segment).ok()?; return on_line(line.trim_end_matches('\r')); } let suffix = std::mem::take(carry); let mut line_bytes = Vec::with_capacity(segment.len() + suffix.len()); line_bytes.extend_from_slice(segment); line_bytes.extend_from_slice(&suffix); let line = std::str::from_utf8(&line_bytes).ok()?; on_line(line.trim_end_matches('\r')) } fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0) } #[cfg(test)] mod tests { use std::fs; use tempfile::tempdir; use super::{InvocationRecord, find_last_record_matching, load_last_record, now_ms}; fn sample_record(project_root: &str, task_name: &str, user_input: &str) -> InvocationRecord { InvocationRecord { timestamp_ms: now_ms(), duration_ms: 1, project_root: project_root.to_string(), project_name: None, config_path: format!("{project_root}/flow.toml"), task_name: task_name.to_string(), command: "echo hi".to_string(), user_input: user_input.to_string(), status: Some(0), success: true, used_flox: false, output: "ok".to_string(), flow_version: "test".to_string(), } } #[test] fn load_last_record_reads_from_end_without_full_file_parse() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("history.jsonl"); let long_output = "x".repeat(super::HISTORY_REVERSE_SCAN_CHUNK_BYTES + 256); let mut first = sample_record("/tmp/a", "first", "first"); first.output = long_output; let second = sample_record("/tmp/b", "second", "second"); let payload = format!( "{}\n{}\n", serde_json::to_string(&first).expect("first json"), serde_json::to_string(&second).expect("second json") ); fs::write(&path, payload).expect("write history"); let found = load_last_record(&path) .expect("load last record") .expect("record should exist"); assert_eq!(found.task_name, "second"); } #[test] fn find_last_record_matching_finds_latest_matching_project() { let dir = tempdir().expect("tempdir"); let project = dir.path().join("project"); fs::create_dir_all(&project).expect("project dir"); let path = dir.path().join("history.jsonl"); let first = sample_record(&project.to_string_lossy(), "one", "one"); let second = sample_record("/tmp/other", "other", "other"); let third = sample_record(&project.to_string_lossy(), "two", "two"); let payload = format!( "{}\n{}\n{}\n", serde_json::to_string(&first).expect("first json"), serde_json::to_string(&second).expect("second json"), serde_json::to_string(&third).expect("third json") ); fs::write(&path, payload).expect("write history"); let found = find_last_record_matching(&path, |rec| rec.project_root == project.to_string_lossy()) .expect("load project record") .expect("record should exist"); assert_eq!(found.task_name, "two"); } } ================================================ FILE: src/hive.rs ================================================ //! Hive agent integration for flow. //! //! Agents can be defined at three levels: //! 1. Project-local: flow.toml [[agents]] or .flow/agents/*.md //! 2. Global: ~/.config/flow/agents/*.md or ~/.hive/agents/ //! 3. Hive registry: ~/.hive/config.json agents //! //! Agent spec format (Markdown): //! ```markdown //! # Agent: <name> //! # Purpose: <description> //! # //! # Rules: //! # - Rule 1 //! # - Rule 2 //! # //! # Tools: //! # - bash //! ``` use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; /// Agent configuration from flow.toml #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct AgentConfig { pub name: String, #[serde(default)] pub description: Option<String>, /// System prompt / preamble (inline or file path) #[serde(default)] pub preamble: Option<String>, /// Path to spec file (relative to project root) #[serde(default)] pub spec: Option<String>, /// Tools available to the agent #[serde(default)] pub tools: Vec<String>, /// Model to use (provider-specific) #[serde(default)] pub model: Option<String>, /// Provider: cerebras, deepseek, zai, groq, openrouter #[serde(default)] pub provider: Option<String>, /// Temperature for generation #[serde(default)] pub temperature: Option<f64>, /// Max tokens #[serde(default, rename = "max_tokens", alias = "maxTokens")] pub max_tokens: Option<u32>, /// Max tool call depth #[serde(default, rename = "max_depth", alias = "maxDepth")] pub max_depth: Option<u32>, /// Keywords to match for auto-routing #[serde(default, rename = "match_on", alias = "matchOn")] pub match_on: Vec<String>, /// Context files to include #[serde(default)] pub context: Vec<String>, /// Shortcuts for quick invocation #[serde(default)] pub shortcuts: Vec<String>, } /// Hive global config from ~/.hive/config.json #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HiveConfig { #[serde(default)] pub agents: HashMap<String, HiveAgentSpec>, #[serde(default)] pub defaults: Option<HiveDefaults>, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HiveAgentSpec { #[serde(default)] pub job: Option<String>, #[serde(default)] pub prompt: Option<String>, #[serde(default, rename = "matchedOn")] pub matched_on: Option<Vec<String>>, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HiveDefaults { #[serde(default)] pub provider: Option<String>, #[serde(default)] pub model: Option<String>, } /// Resolved agent from any source #[derive(Debug, Clone)] pub struct Agent { pub name: String, pub source: AgentSource, pub spec_path: Option<PathBuf>, pub config: AgentConfig, } #[derive(Debug, Clone, PartialEq)] pub enum AgentSource { /// From project flow.toml [[agents]] ProjectConfig, /// From .flow/agents/<name>.md ProjectFile, /// From ~/.config/flow/agents/<name>.md GlobalFlow, /// From ~/.hive/agents/<name>/spec.md GlobalHive, /// From ~/.hive/config.json HiveRegistry, } impl std::fmt::Display for AgentSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AgentSource::ProjectConfig => write!(f, "project"), AgentSource::ProjectFile => write!(f, "project"), AgentSource::GlobalFlow => write!(f, "global"), AgentSource::GlobalHive => write!(f, "hive"), AgentSource::HiveRegistry => write!(f, "hive"), } } } /// Load hive global config pub fn load_hive_config() -> Option<HiveConfig> { let path = dirs::home_dir()?.join(".hive/config.json"); let content = fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() } /// Load project config for agents (best effort, returns default if not found) fn load_config_for_agents() -> crate::config::Config { // Try to find and load flow.toml let config_path = PathBuf::from("flow.toml"); if config_path.exists() { if let Ok(cfg) = crate::config::load(&config_path) { return cfg; } } // Return default config if not found crate::config::Config::default() } /// Find agent spec file in standard locations fn find_agent_spec(name: &str) -> Option<(PathBuf, AgentSource)> { // 1. Project-local: .flow/agents/<name>.md let project_path = PathBuf::from(".flow/agents").join(format!("{}.md", name)); if project_path.exists() { return Some((project_path, AgentSource::ProjectFile)); } // 2. Global flow: ~/.config/flow/agents/<name>.md if let Some(home) = dirs::home_dir() { let global_flow = home .join(".config/flow/agents") .join(format!("{}.md", name)); if global_flow.exists() { return Some((global_flow, AgentSource::GlobalFlow)); } // 3. Hive agents: ~/.hive/agents/<name>/spec.md let hive_spec = home.join(".hive/agents").join(name).join("spec.md"); if hive_spec.exists() { return Some((hive_spec, AgentSource::GlobalHive)); } } None } /// Load agent spec content from file pub fn load_agent_spec(path: &Path) -> Result<String> { fs::read_to_string(path).context(format!("Failed to read agent spec: {}", path.display())) } /// Discover all available agents pub fn discover_agents(project_agents: &[AgentConfig]) -> Vec<Agent> { let mut agents = Vec::new(); let mut seen = std::collections::HashSet::new(); // 1. Project config agents (highest priority) for cfg in project_agents { if seen.insert(cfg.name.clone()) { let spec_path = cfg.spec.as_ref().map(PathBuf::from); agents.push(Agent { name: cfg.name.clone(), source: AgentSource::ProjectConfig, spec_path, config: cfg.clone(), }); } } // 2. Project file agents: .flow/agents/*.md if let Ok(entries) = fs::read_dir(".flow/agents") { for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.extension().map_or(false, |e| e == "md") { let stem = path .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_string()); if let Some(name) = stem { if seen.insert(name.clone()) { agents.push(Agent { name: name.clone(), source: AgentSource::ProjectFile, spec_path: Some(path), config: AgentConfig { name, ..Default::default() }, }); } } } } } // 3. Global flow agents: ~/.config/flow/agents/*.md if let Some(home) = dirs::home_dir() { let global_dir = home.join(".config/flow/agents"); if let Ok(entries) = fs::read_dir(&global_dir) { for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.extension().map_or(false, |e| e == "md") { let stem = path .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_string()); if let Some(name) = stem { if seen.insert(name.clone()) { agents.push(Agent { name: name.clone(), source: AgentSource::GlobalFlow, spec_path: Some(path), config: AgentConfig { name, ..Default::default() }, }); } } } } } // 4. Hive agents: ~/.hive/agents/*/spec.md let hive_dir = home.join(".hive/agents"); if let Ok(entries) = fs::read_dir(&hive_dir) { for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.is_dir() { let spec_path = path.join("spec.md"); if spec_path.exists() { if let Some(name) = path.file_name().and_then(|s| s.to_str()) { if seen.insert(name.to_string()) { agents.push(Agent { name: name.to_string(), source: AgentSource::GlobalHive, spec_path: Some(spec_path), config: AgentConfig { name: name.to_string(), ..Default::default() }, }); } } } } } } } // 5. Hive registry agents: ~/.hive/config.json if let Some(hive_config) = load_hive_config() { for (name, spec) in hive_config.agents { if seen.insert(name.clone()) { agents.push(Agent { name: name.clone(), source: AgentSource::HiveRegistry, spec_path: None, config: AgentConfig { name, description: spec.job.or(spec.prompt), ..Default::default() }, }); } } } agents } /// Run a hive agent with a prompt pub fn run_agent(agent: &str, prompt: &str) -> Result<()> { // Check if hive is available if which::which("hive").is_err() { anyhow::bail!("hive not found on PATH. Install from https://github.com/example/hive"); } let status = Command::new("hive") .arg("agent") .arg(agent) .arg(prompt) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("Failed to run hive")?; if !status.success() { anyhow::bail!( "hive agent '{}' exited with status {:?}", agent, status.code() ); } Ok(()) } /// Run an agent interactively (prompt via stdin) pub fn run_agent_interactive(agent: &str) -> Result<()> { if which::which("hive").is_err() { anyhow::bail!("hive not found on PATH"); } let status = Command::new("hive") .arg("agent") .arg(agent) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("Failed to run hive")?; if !status.success() { anyhow::bail!( "hive agent '{}' exited with status {:?}", agent, status.code() ); } Ok(()) } /// Create a new agent spec file pub fn create_agent(name: &str, global: bool) -> Result<PathBuf> { let path = if global { let home = dirs::home_dir().context("Could not find home directory")?; let dir = home.join(".hive/agents").join(name); fs::create_dir_all(&dir)?; dir.join("spec.md") } else { let dir = PathBuf::from(".flow/agents"); fs::create_dir_all(&dir)?; dir.join(format!("{}.md", name)) }; if path.exists() { anyhow::bail!("Agent '{}' already exists at {}", name, path.display()); } let template = format!( r#"# Agent: {} # Purpose: <describe what this agent does> # # Rules: # - <rule 1> # - <rule 2> # # Tools: # - bash "#, name ); fs::write(&path, template)?; Ok(path) } /// Edit an agent spec file pub fn edit_agent(name: &str) -> Result<()> { let (path, _source) = find_agent_spec(name).ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", name))?; let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); let status = Command::new(&editor) .arg(&path) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context(format!("Failed to open editor '{}'", editor))?; if !status.success() { anyhow::bail!("Editor exited with status {:?}", status.code()); } Ok(()) } /// List agents in a formatted table pub fn list_agents(project_agents: &[AgentConfig]) { let agents = discover_agents(project_agents); if agents.is_empty() { println!("No agents found."); println!("\nCreate one with: f hive new <name>"); return; } println!("{:<20} {:<10} {}", "NAME", "SOURCE", "DESCRIPTION"); println!("{}", "-".repeat(60)); for agent in agents { let desc = agent .config .description .as_deref() .unwrap_or("-") .chars() .take(40) .collect::<String>(); println!("{:<20} {:<10} {}", agent.name, agent.source, desc); } } /// Get agent by name pub fn get_agent(name: &str, project_agents: &[AgentConfig]) -> Option<Agent> { discover_agents(project_agents) .into_iter() .find(|a| a.name == name) } /// Match agents for auto-routing based on content pub fn match_agents(content: &str, project_agents: &[AgentConfig], max: usize) -> Vec<String> { let content_lower = content.to_lowercase(); let mut matches = Vec::new(); // Check project agents first for cfg in project_agents { if !cfg.match_on.is_empty() { let matched = cfg.match_on.iter().any(|term| { let needle = term.to_lowercase(); !needle.is_empty() && content_lower.contains(&needle) }); if matched { matches.push(cfg.name.clone()); } } } // Check hive registry agents if let Some(hive_config) = load_hive_config() { for (name, spec) in hive_config.agents { if let Some(terms) = spec.matched_on { let matched = terms.iter().any(|term| { let needle = term.to_lowercase(); !needle.is_empty() && content_lower.contains(&needle) }); if matched && !matches.contains(&name) { matches.push(name); } } } } matches.truncate(max); matches } /// Handle the `f hive` CLI command. pub fn run_command(cmd: crate::cli::HiveCommand) -> Result<()> { use crate::cli::HiveAction; // Load project config to get agents (if available) let cfg = load_config_for_agents(); // Handle direct agent invocation: `f hive fish "wrap ls"` if !cmd.agent.is_empty() { let agent_name = &cmd.agent[0]; let prompt = if cmd.agent.len() > 1 { cmd.agent[1..].join(" ") } else { String::new() }; if prompt.is_empty() { return run_agent_interactive(agent_name); } else { return run_agent(agent_name, &prompt); } } match cmd.action { None | Some(HiveAction::List) => { list_agents(&cfg.agents); } Some(HiveAction::Run { agent, prompt }) => { let prompt_str = prompt.join(" "); if prompt_str.is_empty() { run_agent_interactive(&agent)?; } else { run_agent(&agent, &prompt_str)?; } } Some(HiveAction::New { name, global }) => { let path = create_agent(&name, global)?; println!("Created agent: {}", path.display()); println!("\nEdit with: f hive edit {}", name); } Some(HiveAction::Edit { agent }) => { if let Some(name) = agent { edit_agent(&name)?; } else { // List agents and ask user to specify let agents = discover_agents(&cfg.agents); if agents.is_empty() { println!("No agents found. Create one with: f hive new <name>"); } else { println!("Available agents:"); for a in agents { println!(" {}", a.name); } println!("\nRun: f hive edit <agent>"); } } } Some(HiveAction::Show { agent }) => { if let Some(a) = get_agent(&agent, &cfg.agents) { if let Some(path) = a.spec_path { let content = load_agent_spec(&path)?; println!("{}", content); } else if let Some(desc) = a.config.description { println!("# Agent: {}\n\n{}", agent, desc); } else { println!("Agent '{}' has no spec file.", agent); } } else { anyhow::bail!("Agent '{}' not found", agent); } } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_agent_source_display() { assert_eq!(format!("{}", AgentSource::ProjectConfig), "project"); assert_eq!(format!("{}", AgentSource::GlobalHive), "hive"); } } ================================================ FILE: src/home.rs ================================================ use std::{ fs, path::{Path, PathBuf}, process::{Command, Stdio}, }; use anyhow::{Context, Result, bail}; use regex::Regex; use serde::Deserialize; use crate::cli::{HomeAction, HomeCommand}; use crate::{config, ssh, ssh_keys}; const DEFAULT_REPOS_ROOT: &str = "~/repos"; #[derive(Debug, Clone)] struct RepoInput { owner: String, repo: String, clone_url: String, scheme: RepoScheme, } #[derive(Debug, Clone, Copy, PartialEq)] enum RepoScheme { Https, Ssh, } #[derive(Debug, Default, Deserialize)] struct HomeConfigFile { #[serde(default)] home: Option<HomeConfigSection>, #[serde(default)] internal_repo: Option<String>, #[serde(default)] internal_repo_url: Option<String>, #[serde(default)] kar_repo: Option<String>, #[serde(default)] kar_repo_url: Option<String>, } #[derive(Debug, Default, Deserialize)] struct HomeConfigSection { #[serde(default)] internal_repo: Option<String>, #[serde(default)] internal_repo_url: Option<String>, #[serde(default)] kar_repo: Option<String>, #[serde(default)] kar_repo_url: Option<String>, } pub fn run(opts: HomeCommand) -> Result<()> { if let Some(action) = opts.action { match action { HomeAction::Setup => return setup(), } } ssh::ensure_ssh_env(); let mode = ssh::ssh_mode(); if matches!(mode, ssh::SshMode::Force) && !ssh::has_identities() { match ssh_keys::ensure_default_identity(24) { Ok(()) => {} Err(err) => println!( "warning: SSH mode is forced but no key is available ({}). Run `f ssh setup` or `f ssh unlock`.", err ), } } let prefer_ssh = ssh::prefer_ssh(); let home = dirs::home_dir().context("Could not find home directory")?; let config_dir = home.join("config"); let repo_str = opts .repo .as_ref() .context("Missing repo. Use `f home <repo>` or `f home setup`.")?; let repo = coerce_repo_scheme(parse_repo_input(repo_str)?, prefer_ssh); let flow_bin = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("f")); ensure_repo(&config_dir, Some(&repo.clone_url), "config", false)?; let internal_url = if let Some(internal) = opts.internal.as_deref() { Some(coerce_repo_url(internal, prefer_ssh)) } else { read_internal_repo(&config_dir)? .map(|url| coerce_repo_url(&url, prefer_ssh)) .or_else(|| derive_internal_repo(&repo)) }; let internal_dir = config_dir.join("i"); if internal_dir.exists() { ensure_repo(&internal_dir, internal_url.as_deref(), "config/i", false)?; } else if let Some(url) = internal_url.as_deref() { ensure_repo(&internal_dir, Some(url), "config/i", false)?; } else { println!( "No internal repo configured; skipping {} (use --internal or add home.toml)", internal_dir.display() ); } let archived = archive_existing_configs(&config_dir)?; apply_config(&config_dir)?; match ssh::ensure_git_ssh_command() { Ok(true) => println!("Configured git to use 1Password SSH agent."), Ok(false) => {} Err(err) => println!("warning: failed to configure git ssh: {}", err), } if !prefer_ssh { match ssh::ensure_git_https_insteadof() { Ok(true) => println!("Configured git to use HTTPS when SSH isn't available."), Ok(false) => {} Err(err) => println!("warning: failed to configure git https rewrites: {}", err), } } if let Some(kar_repo) = resolve_kar_repo(&config_dir)? { ensure_kar_repo(&flow_bin, prefer_ssh, &kar_repo)?; } else { println!("No kar repo configured; skipping kar deploy."); } validate_setup(&config_dir)?; if !archived.is_empty() { println!("\nMoved existing config files to ~/flow-archive:"); for path in archived { println!(" {}", path.display()); } println!("Restore any file by moving it back to its original path."); } Ok(()) } pub fn setup() -> Result<()> { println!("Home setup"); println!("-----------"); if !check_git() { println!("git not found on PATH. Install Xcode Command Line Tools:"); println!(" xcode-select --install"); return Ok(()); } ssh::ensure_ssh_env(); let ssh_check = check_git_access("git@github.com:github/linguist.git"); if ssh_check.ok { println!("✓ GitHub SSH auth works (git@github.com)"); } else { println!("✗ GitHub SSH auth failed (git@github.com)"); } let https_check = check_git_access("https://github.com/github/linguist.git"); if https_check.ok { println!("✓ GitHub HTTPS works (https://github.com)"); } else { println!("✗ GitHub HTTPS failed (https://github.com)"); } if !ssh_check.ok && https_check.ok { match ssh::ensure_git_https_insteadof() { Ok(true) => println!("Configured git to use HTTPS when SSH isn't available."), Ok(false) => {} Err(err) => println!("warning: failed to configure git https rewrites: {}", err), } println!("If you want SSH instead, add your key to GitHub and run:"); println!(" f ssh setup"); println!(" ssh -T git@github.com"); } if !ssh_check.ok && !https_check.ok { println!("GitHub connectivity failed. Check your network or proxy settings."); } if !ssh_check.ok { if ssh_check .stderr .to_lowercase() .contains("permission denied (publickey)") { println!("SSH key is not authorized for GitHub. Add ~/.ssh/id_ed25519.pub to GitHub."); } else if ssh_check .stderr .to_lowercase() .contains("host key verification failed") { println!("Accept GitHub host key first: ssh -T git@github.com"); } } println!("Done."); Ok(()) } fn check_git() -> bool { Command::new("git") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|status| status.success()) .unwrap_or(false) } struct GitCheck { ok: bool, stderr: String, } fn check_git_access(url: &str) -> GitCheck { let output = Command::new("git") .args(["ls-remote", "--heads", url]) .env("GIT_TERMINAL_PROMPT", "0") .output(); match output { Ok(out) => GitCheck { ok: out.status.success(), stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), }, Err(err) => GitCheck { ok: false, stderr: err.to_string(), }, } } fn archive_existing_configs(config_dir: &Path) -> Result<Vec<PathBuf>> { let home = dirs::home_dir().context("Could not find home directory")?; let archive_root = home.join("flow-archive"); let mappings = load_link_mappings(config_dir)?; let mut moved = Vec::new(); for (source_rel, dest_rel) in mappings { let source = config_dir.join(&source_rel); if !source.exists() { continue; } let dest_rel = normalize_dest_rel(&dest_rel)?; let dest = home.join(&dest_rel); if !dest.exists() { continue; } if is_symlink_to(&dest, &source) { continue; } let mut archive_path = archive_root.join(&dest_rel); if archive_path.exists() { archive_path = archive_path.with_extension(format!("bak-{}", chrono::Utc::now().timestamp())); } if let Some(parent) = archive_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } fs::rename(&dest, &archive_path).with_context(|| { format!( "failed to move {} to {}", dest.display(), archive_path.display() ) })?; moved.push(archive_path); } Ok(moved) } fn normalize_dest_rel(dest: &Path) -> Result<PathBuf> { let dest_str = dest.to_string_lossy(); if let Some(stripped) = dest_str.strip_prefix("~/") { return Ok(PathBuf::from(stripped)); } if dest.is_absolute() { bail!( "absolute paths are not supported in sync links: {}", dest.display() ); } Ok(dest.to_path_buf()) } fn is_symlink_to(link: &Path, expected: &Path) -> bool { let meta = match fs::symlink_metadata(link) { Ok(v) => v, Err(_) => return false, }; if !meta.file_type().is_symlink() { return false; } let target = match fs::read_link(link) { Ok(v) => v, Err(_) => return false, }; let resolved = if target.is_absolute() { target } else { link.parent().unwrap_or_else(|| Path::new(".")).join(target) }; let expected = match fs::canonicalize(expected) { Ok(v) => v, Err(_) => return false, }; let resolved = match fs::canonicalize(resolved) { Ok(v) => v, Err(_) => return false, }; resolved == expected } fn load_link_mappings(config_dir: &Path) -> Result<Vec<(PathBuf, PathBuf)>> { let sync_file = config_dir.join("sync").join("src").join("main.ts"); if sync_file.exists() { let raw = fs::read_to_string(&sync_file) .with_context(|| format!("failed to read {}", sync_file.display()))?; let re = Regex::new(r#""([^"]+)"\s*:\s*"([^"]+)""#).context("failed to compile link regex")?; let mut links = Vec::new(); let mut in_links = false; for line in raw.lines() { let trimmed = line.trim(); if trimmed.starts_with("const LINKS") { in_links = true; continue; } if in_links && trimmed.starts_with('}') { break; } if !in_links { continue; } if let Some(caps) = re.captures(trimmed) { let src = caps.get(1).map(|m| m.as_str()).unwrap_or(""); let dst = caps.get(2).map(|m| m.as_str()).unwrap_or(""); if !src.is_empty() && !dst.is_empty() { links.push((PathBuf::from(src), PathBuf::from(dst))); } } } if !links.is_empty() { return Ok(links); } } Ok(default_link_mappings()) } fn default_link_mappings() -> Vec<(PathBuf, PathBuf)> { vec![ ("fish/config.fish", ".config/fish/config.fish"), ("fish/fn.fish", ".config/fish/fn.fish"), ("i/karabiner/karabiner.edn", ".config/karabiner.edn"), ("i/kar", ".config/kar"), ("i/git/.gitconfig", ".gitconfig"), ("i/ssh/config", ".ssh/config"), ("i/ghost/ghost.toml", ".config/ghost/ghost.toml"), ("i/flow", ".config/flow"), ] .into_iter() .map(|(src, dst)| (PathBuf::from(src), PathBuf::from(dst))) .collect() } fn apply_config(config_dir: &Path) -> Result<()> { let sync_script = config_dir.join("sync").join("src").join("main.ts"); if sync_script.exists() { if which::which("bun").is_ok() { run_command( "bun", &[sync_script.to_string_lossy().as_ref(), "link"], Some(config_dir), )?; ensure_link_targets(config_dir)?; return Ok(()); } } if which::which("sync").is_ok() { run_command("sync", &["link"], Some(config_dir))?; ensure_link_targets(config_dir)?; return Ok(()); } let fallback = config_dir.join("sh").join("check-config-setup.sh"); if fallback.exists() { println!("sync not available; falling back to {}", fallback.display()); run_command(fallback.to_string_lossy().as_ref(), &[], Some(config_dir))?; let internal_fallback = config_dir.join("sh").join("ensure-i-dotfiles.sh"); if internal_fallback.exists() { run_command( internal_fallback.to_string_lossy().as_ref(), &[], Some(config_dir), )?; } ensure_link_targets(config_dir)?; return Ok(()); } println!( "sync tool not available; applying symlinks directly from {}", config_dir.display() ); ensure_link_targets(config_dir) } fn ensure_link_targets(config_dir: &Path) -> Result<()> { let home = dirs::home_dir().context("Could not find home directory")?; let mappings = load_link_mappings(config_dir)?; for (source_rel, dest_rel) in mappings { let source = config_dir.join(&source_rel); if !source.exists() { continue; } let dest_rel = normalize_dest_rel(&dest_rel)?; let dest = home.join(&dest_rel); if is_symlink_to(&dest, &source) { continue; } if let Ok(meta) = fs::symlink_metadata(&dest) { if meta.file_type().is_dir() { fs::remove_dir_all(&dest) .with_context(|| format!("failed to remove {}", dest.display()))?; } else { fs::remove_file(&dest) .with_context(|| format!("failed to remove {}", dest.display()))?; } } if let Some(parent) = dest.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } } create_symlink(&source, &dest)?; } Ok(()) } fn create_symlink(source: &Path, dest: &Path) -> Result<()> { #[cfg(unix)] { std::os::unix::fs::symlink(source, dest).with_context(|| { format!( "failed to symlink {} -> {}", dest.display(), source.display() ) })?; return Ok(()); } #[cfg(not(unix))] { bail!("symlinks are only supported on unix-like systems"); } } fn ensure_kar_repo(flow_bin: &Path, prefer_ssh: bool, repo_url: &str) -> Result<()> { let repo_url = coerce_repo_url(repo_url, prefer_ssh); let repo = parse_repo_input(&repo_url)?; let root = config::expand_path(DEFAULT_REPOS_ROOT); let owner_dir = root.join(&repo.owner); let repo_path = owner_dir.join(&repo.repo); ensure_repo(&repo_path, Some(&repo.clone_url), "kar", true)?; let flow_toml = repo_path.join("flow.toml"); if !flow_toml.exists() { println!( "No flow.toml found in {}; skipping f deploy", repo_path.display() ); return Ok(()); } println!("Deploying kar from {}", repo_path.display()); run_command( flow_bin.to_string_lossy().as_ref(), &["deploy"], Some(&repo_path), )?; Ok(()) } fn validate_setup(config_dir: &Path) -> Result<()> { let home = dirs::home_dir().context("Could not find home directory")?; let mappings = load_link_mappings(config_dir)?; let mut missing = Vec::new(); let mut mismatched = Vec::new(); for (source_rel, dest_rel) in &mappings { let source = config_dir.join(source_rel); if !source.exists() { continue; } let dest_rel = normalize_dest_rel(dest_rel)?; let dest = home.join(&dest_rel); if !dest.exists() { missing.push(dest); continue; } if !is_symlink_to(&dest, &source) { mismatched.push((dest, source)); } } let mut critical_missing = Vec::new(); let kar_config = home.join(".config/kar/config.ts"); if !kar_config.exists() { critical_missing.push(kar_config); } let karabiner_config = home.join(".config/karabiner.edn"); if !karabiner_config.exists() { critical_missing.push(karabiner_config); } if missing.is_empty() && mismatched.is_empty() && critical_missing.is_empty() { println!("Validation: all expected configs are in place."); return Ok(()); } println!("\nValidation warnings:"); for path in critical_missing { println!(" missing critical config: {}", path.display()); } for path in missing { println!(" missing link target: {}", path.display()); } for (dest, source) in mismatched { println!( " not linked: {} (expected -> {})", dest.display(), source.display() ); } Ok(()) } fn ensure_repo( dest: &Path, repo_url: Option<&str>, label: &str, allow_origin_reset: bool, ) -> Result<()> { if dest.exists() { if !dest.join(".git").exists() { bail!("{} exists but is not a git repo: {}", label, dest.display()); } if let Some(expected) = repo_url { if allow_origin_reset { ensure_origin_url(dest, expected)?; } else if let Ok(actual) = git_capture(dest, &["remote", "get-url", "origin"]) { if !urls_match(expected, actual.trim()) { bail!( "{} origin mismatch: expected {}, got {}", label, expected, actual.trim() ); } } } update_repo(dest)?; return Ok(()); } let repo_url = repo_url.ok_or_else(|| anyhow::anyhow!("{} repo URL required", label))?; clone_repo(repo_url, dest)?; Ok(()) } fn update_repo(dest: &Path) -> Result<()> { run_command("git", &["fetch", "--prune", "origin"], Some(dest))?; let branch = default_branch(dest)?; run_command( "git", &["checkout", "-B", &branch, &format!("origin/{}", branch)], Some(dest), )?; run_command( "git", &["reset", "--hard", &format!("origin/{}", branch)], Some(dest), )?; println!("Updated {}", dest.display()); Ok(()) } fn clone_repo(repo_url: &str, dest: &Path) -> Result<()> { if let Some(parent) = dest.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } } run_command( "git", &["clone", repo_url, dest.to_string_lossy().as_ref()], None, )?; println!("Cloned {}", dest.display()); Ok(()) } fn ensure_origin_url(dest: &Path, expected: &str) -> Result<()> { match git_capture(dest, &["remote", "get-url", "origin"]) { Ok(actual) => { let actual = actual.trim(); let mut needs_reset = !urls_match(expected, actual); if !needs_reset { if let (Some(expected_scheme), Some(actual_scheme)) = (scheme_for_url(expected), scheme_for_url(actual)) { if expected_scheme != actual_scheme { needs_reset = true; } } } if needs_reset { run_command( "git", &["remote", "set-url", "origin", expected], Some(dest), )?; } } Err(_) => { run_command("git", &["remote", "add", "origin", expected], Some(dest))?; } } Ok(()) } fn default_branch(dest: &Path) -> Result<String> { if let Ok(head) = git_capture(dest, &["symbolic-ref", "refs/remotes/origin/HEAD"]) { if let Some(branch) = head.trim().rsplit('/').next() { if !branch.is_empty() { return Ok(branch.to_string()); } } } if git_ref_exists(dest, "refs/remotes/origin/main")? { return Ok("main".to_string()); } if git_ref_exists(dest, "refs/remotes/origin/master")? { return Ok("master".to_string()); } Ok("main".to_string()) } fn git_ref_exists(dest: &Path, reference: &str) -> Result<bool> { let status = Command::new("git") .args(["rev-parse", "--verify", "--quiet", reference]) .current_dir(dest) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .context("failed to run git")?; Ok(status.success()) } fn git_capture(dest: &Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .args(args) .current_dir(dest) .stdin(Stdio::null()) .output() .context("failed to run git")?; if !output.status.success() { bail!("git {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn run_command(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> { let mut command = Command::new(cmd); command.args(args); if let Some(dir) = cwd { command.current_dir(dir); } let status = command .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .with_context(|| format!("failed to run {}", cmd))?; if !status.success() { bail!("{} failed with status {}", cmd, status); } Ok(()) } fn read_internal_repo(config_dir: &Path) -> Result<Option<String>> { let candidates = [config_dir.join("home.toml"), config_dir.join(".home.toml")]; for path in candidates { if !path.exists() { continue; } let raw = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let parsed: HomeConfigFile = toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?; let from_section = parsed .home .as_ref() .and_then(|h| h.internal_repo.clone().or(h.internal_repo_url.clone())); let flat = parsed.internal_repo.or(parsed.internal_repo_url); if from_section.is_some() { return Ok(from_section); } if flat.is_some() { return Ok(flat); } } Ok(None) } fn resolve_kar_repo(config_dir: &Path) -> Result<Option<String>> { if let Ok(value) = std::env::var("FLOW_HOME_KAR_REPO") { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(Some(trimmed.to_string())); } } read_kar_repo(config_dir) } fn read_kar_repo(config_dir: &Path) -> Result<Option<String>> { let candidates = [config_dir.join("home.toml"), config_dir.join(".home.toml")]; for path in candidates { if !path.exists() { continue; } let raw = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let parsed: HomeConfigFile = toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?; let from_section = parsed .home .as_ref() .and_then(|h| h.kar_repo.clone().or(h.kar_repo_url.clone())); let flat = parsed.kar_repo.or(parsed.kar_repo_url); if from_section.is_some() { return Ok(from_section); } if flat.is_some() { return Ok(flat); } } Ok(None) } fn derive_internal_repo(repo: &RepoInput) -> Option<String> { let suffix = format!("{}-i", repo.repo); match repo.scheme { RepoScheme::Https => Some(format!("https://github.com/{}/{}.git", repo.owner, suffix)), RepoScheme::Ssh => Some(format!("git@github.com:{}/{}.git", repo.owner, suffix)), } } fn parse_repo_input(input: &str) -> Result<RepoInput> { let trimmed = input.trim().trim_end_matches('/'); if trimmed.is_empty() { bail!("repo URL is required"); } if let Some(rest) = trimmed.strip_prefix("git@github.com:") { return parse_owner_repo(rest, RepoScheme::Ssh); } if let Some(rest) = trimmed.strip_prefix("ssh://git@github.com/") { return parse_owner_repo(rest, RepoScheme::Ssh); } if let Some(rest) = trimmed.strip_prefix("https://github.com/") { return parse_owner_repo(rest, RepoScheme::Https); } if let Some(rest) = trimmed.strip_prefix("http://github.com/") { return parse_owner_repo(rest, RepoScheme::Https); } if let Some(rest) = trimmed.strip_prefix("github.com/") { return parse_owner_repo(rest, RepoScheme::Https); } if trimmed.contains('/') { return parse_owner_repo(trimmed, RepoScheme::Https); } bail!("unable to parse GitHub repo from: {}", input) } fn parse_owner_repo(raw: &str, scheme: RepoScheme) -> Result<RepoInput> { let cleaned = raw.trim().trim_end_matches(".git").trim_end_matches('/'); let mut parts = cleaned.splitn(2, '/'); let owner = parts.next().unwrap_or("").trim(); let repo = parts.next().unwrap_or("").trim(); if owner.is_empty() || repo.is_empty() { bail!("unable to parse GitHub repo from: {}", raw); } let clone_url = match scheme { RepoScheme::Https => format!("https://github.com/{}/{}.git", owner, repo), RepoScheme::Ssh => format!("git@github.com:{}/{}.git", owner, repo), }; Ok(RepoInput { owner: owner.to_string(), repo: repo.to_string(), clone_url, scheme, }) } fn coerce_repo_scheme(repo: RepoInput, prefer_ssh: bool) -> RepoInput { let desired = if prefer_ssh { RepoScheme::Ssh } else { RepoScheme::Https }; if repo.scheme == desired { return repo; } let clone_url = match desired { RepoScheme::Https => format!("https://github.com/{}/{}.git", repo.owner, repo.repo), RepoScheme::Ssh => format!("git@github.com:{}/{}.git", repo.owner, repo.repo), }; RepoInput { owner: repo.owner, repo: repo.repo, clone_url, scheme: desired, } } fn coerce_repo_url(raw: &str, prefer_ssh: bool) -> String { match parse_repo_input(raw) { Ok(repo) => coerce_repo_scheme(repo, prefer_ssh).clone_url, Err(_) => raw.to_string(), } } fn urls_match(a: &str, b: &str) -> bool { normalize_repo_url(a) == normalize_repo_url(b) } fn normalize_repo_url(raw: &str) -> String { let trimmed = raw.trim().trim_end_matches('/').trim_end_matches(".git"); if let Some(rest) = trimmed.strip_prefix("git@github.com:") { return format!("github.com/{}", rest); } if let Some(rest) = trimmed.strip_prefix("ssh://git@github.com/") { return format!("github.com/{}", rest); } if let Some(rest) = trimmed.strip_prefix("https://github.com/") { return format!("github.com/{}", rest); } if let Some(rest) = trimmed.strip_prefix("http://github.com/") { return format!("github.com/{}", rest); } if let Some(rest) = trimmed.strip_prefix("github.com/") { return format!("github.com/{}", rest); } trimmed.to_string() } fn scheme_for_url(raw: &str) -> Option<RepoScheme> { let trimmed = raw.trim(); if trimmed.starts_with("git@github.com:") || trimmed.starts_with("ssh://git@github.com/") { return Some(RepoScheme::Ssh); } if trimmed.starts_with("https://github.com/") || trimmed.starts_with("http://github.com/") { return Some(RepoScheme::Https); } None } ================================================ FILE: src/http_client.rs ================================================ use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use std::time::Duration; use anyhow::{Context, Result}; use reqwest::blocking::Client; static BLOCKING_CLIENTS: OnceLock<Mutex<HashMap<u64, Client>>> = OnceLock::new(); fn timeout_key(timeout: Duration) -> u64 { timeout.as_millis().min(u64::MAX as u128) as u64 } /// Reuse blocking reqwest clients by timeout bucket to avoid repeated TLS/client init. pub fn blocking_with_timeout(timeout: Duration) -> Result<Client> { let clients = BLOCKING_CLIENTS.get_or_init(|| Mutex::new(HashMap::new())); let key = timeout_key(timeout); let mut guard = clients .lock() .map_err(|_| anyhow::anyhow!("http client cache mutex poisoned"))?; if let Some(client) = guard.get(&key) { return Ok(client.clone()); } let client = Client::builder() .timeout(timeout) .build() .with_context(|| format!("failed to build http client with timeout {:?}", timeout))?; guard.insert(key, client.clone()); Ok(client) } ================================================ FILE: src/hub.rs ================================================ use std::{net::IpAddr, time::Duration}; use anyhow::Result; use reqwest::blocking::Client; use crate::{ cli::{HubAction, HubCommand, HubOpts}, daemon, docs, supervisor, }; /// Flow acts as a thin launcher that makes sure the lin hub daemon is running. pub fn run(cmd: HubCommand) -> Result<()> { let action = cmd.action.unwrap_or(HubAction::Start); let opts = cmd.opts; match action { HubAction::Start => { ensure_daemon(&opts)?; if opts.docs_hub { let docs_opts = crate::cli::DocsHubOpts { host: "127.0.0.1".to_string(), port: 4410, hub_root: "~/.config/flow/docs-hub".to_string(), template_root: "~/new/docs".to_string(), code_root: "~/code".to_string(), org_root: "~/org".to_string(), no_ai: true, no_open: true, sync_only: false, }; docs::ensure_docs_hub_daemon(&docs_opts)?; } Ok(()) } HubAction::Stop => { stop_daemon(&opts)?; docs::stop_docs_hub_daemon()?; Ok(()) } } } fn ensure_daemon(opts: &HubOpts) -> Result<()> { let host = opts.host; let port = opts.port; if hub_healthy(host, port) { if !opts.no_ui { println!( "Lin watcher daemon already running at {}", format_addr(host, port) ); } return Ok(()); } supervisor::ensure_running(true, !opts.no_ui)?; let action = crate::cli::DaemonAction::Start { name: "lin".to_string(), }; if !supervisor::try_handle_daemon_action(&action, None)? { daemon::start_daemon_with_path("lin", None)?; } if !opts.no_ui { println!("Lin watcher daemon ensured at {}", format_addr(host, port)); } Ok(()) } fn stop_daemon(opts: &HubOpts) -> Result<()> { let action = crate::cli::DaemonAction::Stop { name: "lin".to_string(), }; if supervisor::is_running() { if !supervisor::try_handle_daemon_action(&action, None)? { daemon::stop_daemon_with_path("lin", None)?; } } else { daemon::stop_daemon_with_path("lin", None)?; } if !opts.no_ui { println!("Lin hub stopped (if it was running)."); } Ok(()) } /// Check if the hub is healthy and responding. pub fn hub_healthy(host: IpAddr, port: u16) -> bool { let url = format_health_url(host, port); let client = Client::builder() .timeout(Duration::from_millis(750)) .build(); let Ok(client) = client else { return false; }; client .get(url) .send() .and_then(|resp| resp.error_for_status()) .map(|_| true) .unwrap_or(false) } fn format_addr(host: IpAddr, port: u16) -> String { match host { IpAddr::V4(_) => format!("http://{host}:{port}"), IpAddr::V6(_) => format!("http://[{host}]:{port}"), } } fn format_health_url(host: IpAddr, port: u16) -> String { match host { IpAddr::V4(_) => format!("http://{host}:{port}/health"), IpAddr::V6(_) => format!("http://[{host}]:{port}/health"), } } ================================================ FILE: src/indexer.rs ================================================ use std::{ env, fs, path::{Path, PathBuf}, process::Command, time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result, bail}; use rusqlite::Connection; use crate::cli::IndexOpts; pub fn run(opts: IndexOpts) -> Result<()> { let codanna_path = which::which(&opts.binary).with_context(|| { format!( "failed to locate '{}' on PATH – install Codanna or pass --binary", opts.binary ) })?; let project_root = resolve_project_root(opts.project_root)?; ensure_codanna_initialized(&codanna_path, &project_root)?; run_codanna_index(&codanna_path, &project_root)?; let payload = capture_index_stats(&codanna_path, &project_root)?; let db_path = persist_snapshot(&project_root, &codanna_path, &payload, opts.database)?; println!("Codanna index snapshot stored at {}", db_path.display()); Ok(()) } fn resolve_project_root(path: Option<PathBuf>) -> Result<PathBuf> { let raw_path = match path { Some(p) if p.is_absolute() => p, Some(p) => env::current_dir()?.join(p), None => env::current_dir()?, }; raw_path .canonicalize() .with_context(|| format!("failed to resolve project root at {}", raw_path.display())) } fn ensure_codanna_initialized(binary: &Path, project_root: &Path) -> Result<()> { let settings = project_root.join(".codanna/settings.toml"); if settings.exists() { return Ok(()); } println!( "No Codanna settings found at {} – running 'codanna init'.", settings.display() ); let status = Command::new(binary) .arg("init") .current_dir(project_root) .status() .with_context(|| "failed to spawn 'codanna init'")?; if status.success() { Ok(()) } else { bail!( "'codanna init' exited with status {}", status.code().unwrap_or(-1) ); } } fn run_codanna_index(binary: &Path, project_root: &Path) -> Result<()> { println!("Indexing project {} via Codanna...", project_root.display()); let status = Command::new(binary) .arg("index") .arg("--progress") .arg(".") .current_dir(project_root) .status() .with_context(|| "failed to spawn 'codanna index'")?; if status.success() { Ok(()) } else { bail!( "'codanna index' exited with status {}", status.code().unwrap_or(-1) ); } } fn capture_index_stats(binary: &Path, project_root: &Path) -> Result<String> { println!("Fetching Codanna index metadata..."); let output = Command::new(binary) .arg("mcp") .arg("get_index_info") .arg("--json") .current_dir(project_root) .output() .with_context(|| "failed to run 'codanna mcp get_index_info --json'")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("'codanna mcp get_index_info' failed: {}", stderr.trim()); } let json: serde_json::Value = serde_json::from_slice(&output.stdout) .with_context(|| "failed to parse JSON from 'codanna mcp get_index_info --json'")?; serde_json::to_string_pretty(&json).with_context(|| "failed to serialize Codanna stats payload") } fn persist_snapshot( project_root: &Path, binary: &Path, payload: &str, override_path: Option<PathBuf>, ) -> Result<PathBuf> { let db_path = override_path.unwrap_or_else(default_db_path); if let Some(parent) = db_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } let conn = Connection::open(&db_path) .with_context(|| format!("failed to open sqlite database at {}", db_path.display()))?; conn.execute( "CREATE TABLE IF NOT EXISTS index_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo_path TEXT NOT NULL, codanna_binary TEXT NOT NULL, indexed_at INTEGER NOT NULL, payload TEXT NOT NULL )", [], ) .with_context(|| "failed to create index_runs table")?; let repo_str = project_root.display().to_string(); let binary_str = binary.display().to_string(); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .with_context(|| "system clock before UNIX_EPOCH")? .as_secs() as i64; conn.execute( "INSERT INTO index_runs (repo_path, codanna_binary, indexed_at, payload) VALUES (?1, ?2, ?3, ?4)", (&repo_str, &binary_str, timestamp, payload), ) .with_context(|| "failed to insert index snapshot")?; Ok(db_path) } fn default_db_path() -> PathBuf { if let Some(home) = env::var_os("HOME") { PathBuf::from(home).join(".db/flow/flow.sqlite") } else { PathBuf::from(".db/flow/flow.sqlite") } } ================================================ FILE: src/info.rs ================================================ use std::{ collections::HashSet, fs, path::{Path, PathBuf}, }; use anyhow::Result; use serde::Deserialize; /// Show project information including git remotes and flow.toml settings. pub fn run() -> Result<()> { let cwd = std::env::current_dir()?; println!("Project: {}", cwd.display()); println!(); if let Some(git) = git_info(&cwd) { print_git_info(&git); } else { println!("Git: not a git repository"); } println!(); // Show flow.toml info if let Some(flow_config) = crate::project_snapshot::find_flow_toml_upwards(&cwd) { print_flow_info(&flow_config); } else { println!("Flow: no flow.toml found"); } Ok(()) } #[derive(Debug)] struct GitInfo { branch: Option<String>, remotes: Vec<(String, String)>, } #[derive(Debug)] struct GitRepoPaths { git_dir: PathBuf, common_dir: PathBuf, } fn print_git_info(git: &GitInfo) { if let Some(branch) = git.branch.as_deref() { println!("Branch: {}", branch); } if !git.remotes.is_empty() { println!(); println!("Remotes:"); for (name, url) in &git.remotes { println!(" {} = {}", name, url); } } if let Some((_, upstream)) = git .remotes .iter() .find(|(name, _)| name.eq_ignore_ascii_case("upstream")) { println!(); println!("Upstream: {}", upstream); println!(" Run `f sync` to pull from upstream and push to origin"); } } fn git_info(cwd: &Path) -> Option<GitInfo> { let repo_root = find_git_root(cwd)?; let repo = resolve_git_paths(&repo_root)?; let branch = read_git_branch(&repo.git_dir.join("HEAD")); let remotes = parse_git_remotes(&repo.common_dir.join("config")); Some(GitInfo { branch, remotes }) } fn find_git_root(start: &Path) -> Option<PathBuf> { let mut current = if start.is_dir() { start.to_path_buf() } else { start.parent()?.to_path_buf() }; loop { let dot_git = current.join(".git"); if dot_git.is_dir() || dot_git.is_file() { return Some(current); } if !current.pop() { return None; } } } fn resolve_git_paths(repo_root: &Path) -> Option<GitRepoPaths> { let dot_git = repo_root.join(".git"); let git_dir = if dot_git.is_dir() { dot_git } else { resolve_git_dir_file(&dot_git)? }; let common_dir = resolve_common_git_dir(&git_dir); Some(GitRepoPaths { git_dir, common_dir, }) } fn resolve_git_dir_file(dot_git_file: &Path) -> Option<PathBuf> { let content = fs::read_to_string(dot_git_file).ok()?; let gitdir = content.strip_prefix("gitdir:")?.trim(); let path = PathBuf::from(gitdir); let resolved = if path.is_absolute() { path } else { dot_git_file.parent()?.join(path) }; Some(resolved.canonicalize().unwrap_or(resolved)) } fn resolve_common_git_dir(git_dir: &Path) -> PathBuf { let commondir = git_dir.join("commondir"); let Ok(content) = fs::read_to_string(&commondir) else { return git_dir.to_path_buf(); }; let trimmed = content.trim(); if trimmed.is_empty() { return git_dir.to_path_buf(); } let path = PathBuf::from(trimmed); let resolved = if path.is_absolute() { path } else { git_dir.join(path) }; resolved.canonicalize().unwrap_or(resolved) } fn read_git_branch(head_path: &Path) -> Option<String> { let content = fs::read_to_string(head_path).ok()?; let head = content.trim(); let branch = head.strip_prefix("ref: refs/heads/")?.trim(); if branch.is_empty() { None } else { Some(branch.to_string()) } } fn parse_git_remotes(config_path: &Path) -> Vec<(String, String)> { let Ok(content) = fs::read_to_string(config_path) else { return Vec::new(); }; let mut remotes = Vec::new(); let mut seen = HashSet::new(); let mut current_remote: Option<String> = None; for raw_line in content.lines() { let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with(';') { continue; } if line.starts_with('[') && line.ends_with(']') { current_remote = parse_remote_section(line); continue; } let Some(remote) = current_remote.as_deref() else { continue; }; let Some((key, value)) = line.split_once('=') else { continue; }; if key.trim().eq_ignore_ascii_case("url") { let url = value.trim().to_string(); let dedupe_key = format!("{remote}\n{url}"); if seen.insert(dedupe_key) { remotes.push((remote.to_string(), url)); } } } remotes } fn parse_remote_section(section: &str) -> Option<String> { let inner = section.strip_prefix('[')?.strip_suffix(']')?.trim(); let rest = inner.strip_prefix("remote")?.trim(); let name = rest.strip_prefix('"')?.strip_suffix('"')?.trim(); if name.is_empty() { None } else { Some(name.to_string()) } } fn print_flow_info(flow_toml: &Path) { let content = match std::fs::read_to_string(flow_toml) { Ok(c) => c, Err(_) => return, }; let parsed: InfoConfig = match toml::from_str(&content) { Ok(v) => v, Err(_) => return, }; println!("Flow: {}", flow_toml.display()); // Show [flow] section info if let Some(flow) = parsed.flow.as_ref() { if let Some(name) = flow.name.as_deref() { println!(" name = {}", name); } if let Some(upstream) = flow.upstream.as_deref() { println!(" upstream = {}", upstream); } } if let Some(upstream) = parsed.upstream.as_ref() { println!(); println!("[upstream]"); if let Some(url) = upstream.url.as_deref() { println!(" url = {}", url); } if let Some(branch) = upstream.branch.as_deref() { println!(" branch = {}", branch); } } if !parsed.tasks.is_empty() { println!(); println!("Tasks: {}", parsed.tasks.len()); } } #[derive(Debug, Deserialize)] struct InfoConfig { #[serde(default)] flow: Option<InfoFlowSection>, #[serde(default)] upstream: Option<InfoUpstreamSection>, #[serde(default)] tasks: Vec<InfoTaskSection>, } #[derive(Debug, Deserialize)] struct InfoFlowSection { #[serde(default)] name: Option<String>, #[serde(default)] upstream: Option<String>, } #[derive(Debug, Deserialize)] struct InfoUpstreamSection { #[serde(default)] url: Option<String>, #[serde(default)] branch: Option<String>, } #[derive(Debug, Deserialize)] struct InfoTaskSection {} #[cfg(test)] mod tests { use std::fs; use tempfile::tempdir; use super::{ find_git_root, parse_git_remotes, read_git_branch, resolve_common_git_dir, resolve_git_dir_file, }; #[test] fn parse_git_remotes_reads_unique_remote_urls() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("config"); fs::write( &path, r#" [remote "origin"] url = git@github.com:nikivdev/flow.git fetch = +refs/heads/*:refs/remotes/origin/* [remote "origin"] url = git@github.com:nikivdev/flow.git [remote "upstream"] url = git@github.com:openai/codex.git "#, ) .expect("write config"); let remotes = parse_git_remotes(&path); assert_eq!(remotes.len(), 2); assert_eq!(remotes[0].0, "origin"); assert_eq!(remotes[1].0, "upstream"); } #[test] fn read_git_branch_reads_symbolic_head() { let dir = tempdir().expect("tempdir"); let head = dir.path().join("HEAD"); fs::write(&head, "ref: refs/heads/main\n").expect("write head"); assert_eq!(read_git_branch(&head).as_deref(), Some("main")); } #[test] fn resolve_git_dir_file_supports_relative_gitdir() { let dir = tempdir().expect("tempdir"); let repo = dir.path().join("repo"); let actual = dir.path().join("actual-git"); fs::create_dir_all(&repo).expect("repo dir"); fs::create_dir_all(&actual).expect("git dir"); let dot_git = repo.join(".git"); fs::write(&dot_git, "gitdir: ../actual-git\n").expect("write gitdir"); let resolved = resolve_git_dir_file(&dot_git).expect("resolve gitdir"); assert_eq!(resolved, actual.canonicalize().unwrap_or(actual)); } #[test] fn resolve_common_git_dir_uses_commondir_when_present() { let dir = tempdir().expect("tempdir"); let git_dir = dir.path().join("git/worktrees/repo"); let common = dir.path().join("git"); fs::create_dir_all(&git_dir).expect("gitdir"); fs::create_dir_all(&common).expect("common dir"); fs::write(git_dir.join("commondir"), "../..\n").expect("write commondir"); let resolved = resolve_common_git_dir(&git_dir); assert_eq!(resolved, common.canonicalize().unwrap_or(common)); } #[test] fn find_git_root_walks_up_to_repo_root() { let dir = tempdir().expect("tempdir"); let repo = dir.path().join("repo"); let nested = repo.join("a/b"); fs::create_dir_all(repo.join(".git")).expect("git dir"); fs::create_dir_all(&nested).expect("nested dir"); let root = find_git_root(&nested).expect("git root"); assert_eq!(root, repo); } } ================================================ FILE: src/init.rs ================================================ use std::{ fs, path::{Path, PathBuf}, }; use anyhow::{Context, Result, bail}; use crate::cli::InitOpts; const TEMPLATE: &str = r#"version = 1 [[tasks]] name = "setup" command = "" description = "Project setup (fill me)" shortcuts = ["s"] [[tasks]] name = "dev" command = "" description = "Start dev server (fill me)" dependencies = ["setup"] shortcuts = ["d"] [skills] sync_tasks = true install = ["quality-bun-feature-delivery"] [skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false [commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"] [commit.skill_gate.min_version] quality-bun-feature-delivery = 2 # Bun-focused optional test gate: # #[commit.testing] #mode = "block" #runner = "bun" #bun_repo_strict = true #require_related_tests = true #ai_scratch_test_dir = ".ai/test" #run_ai_scratch_tests = true #allow_ai_scratch_to_satisfy_gate = false #max_local_gate_seconds = 20 "#; pub(crate) fn write_template(path: &Path) -> Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } } fs::write(path, TEMPLATE).with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } pub fn run(opts: InitOpts) -> Result<()> { let target = resolve_path(opts.path); if target.exists() { bail!("{} already exists; refusing to overwrite", target.display()); } write_template(&target)?; println!("created {}", target.display()); Ok(()) } fn resolve_path(path: Option<PathBuf>) -> PathBuf { match path { Some(p) if p.is_absolute() => p, Some(p) => std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(p), None => std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("flow.toml"), } } #[cfg(test)] mod tests { use super::*; #[test] fn template_includes_codex_skill_baseline() { assert!(TEMPLATE.contains("[skills]")); assert!(TEMPLATE.contains("install = [\"quality-bun-feature-delivery\"]")); assert!(TEMPLATE.contains("[skills.codex]")); assert!(TEMPLATE.contains("[commit.skill_gate]")); assert!(TEMPLATE.contains("quality-bun-feature-delivery = 2")); } } ================================================ FILE: src/install.rs ================================================ use std::collections::HashMap; use std::env; use std::fs; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use serde::Deserialize; use crate::cli::{InstallBackend, InstallIndexOpts, InstallOpts}; use crate::config::FloxInstallSpec; use crate::registry; pub fn run(mut opts: InstallOpts) -> Result<()> { if opts .name .as_deref() .map(|name| name.trim().is_empty()) .unwrap_or(true) { opts.backend = InstallBackend::Flox; opts.name = Some(prompt_flox_package()?); } match opts.backend { InstallBackend::Registry => registry::install(normalize_registry_install_opts(opts)), InstallBackend::Flox => install_with_flox(&opts), InstallBackend::Parm => install_with_parm(&opts), InstallBackend::Auto => install_with_auto(&opts), } } fn install_with_auto(opts: &InstallOpts) -> Result<()> { let mut errors: Vec<String> = Vec::new(); if registry_configured(opts) { let registry_opts = normalize_registry_install_opts(opts.clone()); match registry::install(registry_opts) { Ok(()) => return Ok(()), Err(err) => { if is_existing_destination_error(&err) { return Err(err); } eprintln!("WARN registry install failed: {err}"); errors.push(format!("registry: {err}")); } } } if should_try_parm(opts) { match install_with_parm(opts) { Ok(()) => return Ok(()), Err(err) => { if is_existing_destination_error(&err) { return Err(err); } eprintln!("WARN parm install failed: {err}"); errors.push(format!("parm: {err}")); } } } else if let Some(name) = opts .name .as_deref() .map(str::trim) .filter(|n| !n.is_empty()) { eprintln!( "INFO skipping parm fallback for '{}' (no owner/repo mapping; set FLOW_INSTALL_OWNER or pass owner/repo)", name ); } match install_with_flox(opts) { Ok(()) => Ok(()), Err(err) => { errors.push(format!("flox: {err}")); bail!( "install failed after trying auto backends:\n- {}", errors.join("\n- ") ); } } } fn is_existing_destination_error(err: &anyhow::Error) -> bool { err.to_string().contains("already exists") } pub fn run_index(opts: InstallIndexOpts) -> Result<()> { let flox_bin = resolve_flox_bin()?; let Some(config) = typesense_config_with_overrides(&opts) else { bail!("Typesense config missing (set FLOW_TYPESENSE_URL or pass --url)"); }; let queries = load_index_queries(opts.query, opts.queries)?; if queries.is_empty() { bail!("no queries provided"); } let mut all_entries: HashMap<String, FloxDisplayEntry> = HashMap::new(); for query in queries { let results = flox_search_with_aliases(&flox_bin, &query)?; for entry in results { all_entries.entry(entry.pkg_path.clone()).or_insert(entry); } } if all_entries.is_empty() { println!("No results to index."); return Ok(()); } if opts.dry_run { println!("Would index {} packages into Typesense.", all_entries.len()); return Ok(()); } typesense_ensure_collection(&config)?; typesense_import(&config, all_entries.values().cloned().collect())?; println!("Indexed {} packages into Typesense.", all_entries.len()); Ok(()) } fn registry_configured(_opts: &InstallOpts) -> bool { // Registry is always available — defaults to https://myflow.sh true } fn install_with_flox(opts: &InstallOpts) -> Result<()> { let name = opts.name.as_deref().unwrap_or("").trim(); if name.is_empty() { bail!("package name is required"); } let install_root = tool_root()?; let flox_pkg = resolve_flox_pkg_name(name); let spec = FloxInstallSpec { pkg_path: flox_pkg.to_string(), pkg_group: Some("tools".to_string()), version: opts.version.clone(), systems: None, priority: None, }; ensure_flox_tools_env(&install_root, &[(flox_pkg.to_string(), spec)])?; let bin_name = opts.bin.clone().unwrap_or_else(|| name.to_string()); let bin_dir = opts.bin_dir.clone().unwrap_or_else(default_bin_dir); fs::create_dir_all(&bin_dir) .with_context(|| format!("failed to create {}", bin_dir.display()))?; let shim_path = bin_dir.join(&bin_name); if shim_path.exists() && !opts.force { if shim_matches(&shim_path, &install_root, &bin_name).unwrap_or(false) { println!("{} already installed via flox.", bin_name); return Ok(()); } if prompt_overwrite(&shim_path)? { // continue and overwrite } else { bail!( "{} already exists (use --force to overwrite or --bin to install under a different name)", shim_path.display() ); } } write_flox_shim(&shim_path, &install_root, &bin_name)?; if flox_pkg != name { println!( "Installed {} (flox package {}) via flox (shim at {})", name, flox_pkg, shim_path.display() ); } else { println!( "Installed {} via flox (shim at {})", name, shim_path.display() ); } if !path_in_env(&bin_dir) { println!("Add {} to PATH to use it everywhere.", bin_dir.display()); } Ok(()) } fn install_with_parm(opts: &InstallOpts) -> Result<()> { let name = opts.name.as_deref().unwrap_or("").trim(); if name.is_empty() { bail!("package name is required"); } if !opts.bin.is_none() { // Parm determines which executables exist inside the release asset. // We keep Flow's `--bin` flag for other backends, but it doesn't map cleanly. eprintln!("Note: --bin is ignored for --backend parm"); } if opts.force { eprintln!("Note: --force is ignored for --backend parm"); } let bin_dir = opts.bin_dir.clone().unwrap_or_else(default_bin_dir); fs::create_dir_all(&bin_dir) .with_context(|| format!("failed to create {}", bin_dir.display()))?; let owner_repo = resolve_owner_repo(name)?; let owner_repo = match opts .version .as_deref() .map(|v| v.trim()) .filter(|v| !v.is_empty()) { Some(version) => format!("{}@{}", owner_repo, version), None => owner_repo, }; let parm_bin = which::which("parm").context( "parm not found on PATH. Install it first (macOS/Linux):\n curl -fsSL https://raw.githubusercontent.com/yhoundz/parm/master/scripts/install.sh | sh", )?; // Configure parm to symlink into the same directory Flow uses for tools. // This makes installs predictable and avoids relying on parm defaults. let config_status = Command::new(&parm_bin) .args([ "config", "set", &format!("parm_bin_path={}", bin_dir.display()), ]) .stdin(Stdio::null()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run parm config set")?; if !config_status.success() { bail!("parm config set failed"); } let mut cmd = Command::new(&parm_bin); cmd.args(["install", &owner_repo]); if opts.no_verify { cmd.arg("--no-verify"); } if let Some(token) = resolve_github_token()? { cmd.env("PARM_GITHUB_TOKEN", token); } let status = cmd .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run parm install")?; if !status.success() { bail!("parm install failed"); } if !path_in_env(&bin_dir) { println!("Add {} to PATH to use it everywhere.", bin_dir.display()); } Ok(()) } fn resolve_owner_repo(raw: &str) -> Result<String> { if raw.contains('/') { return Ok(raw.to_string()); } if let Some(mapped) = known_owner_repo(raw) { return Ok(mapped.to_string()); } // Prefer explicit env var; fall back to Flow personal env store. let owner = resolve_install_owner(); let Some(owner) = owner else { bail!( "package name '{}' is missing owner (expected owner/repo).\nSet FLOW_INSTALL_OWNER (env or Flow personal env store), use a known alias (flow/rise), or pass owner/repo directly.", raw ); }; Ok(format!("{}/{}", owner, raw)) } fn should_try_parm(opts: &InstallOpts) -> bool { let Some(name) = opts .name .as_deref() .map(str::trim) .filter(|n| !n.is_empty()) else { return false; }; name.contains('/') || known_owner_repo(name).is_some() || resolve_install_owner().is_some() } fn known_owner_repo(name: &str) -> Option<&'static str> { match name { "f" | "flow" | "lin" => Some("nikivdev/flow"), "rise" => Some("nikivdev/rise"), "seq" | "seqd" => Some("nikivdev/seq"), _ => None, } } fn normalize_registry_install_opts(mut opts: InstallOpts) -> InstallOpts { let Some(raw) = opts.name.clone().map(|n| n.trim().to_string()) else { return opts; }; if raw.is_empty() { return opts; } let (package, default_bin) = registry_alias(&raw); if package != raw { opts.name = Some(package.to_string()); if opts.bin.is_none() { if let Some(bin) = default_bin { opts.bin = Some(bin.to_string()); } } } opts } fn registry_alias(raw: &str) -> (&str, Option<&str>) { match raw { "f" => ("flow", Some("f")), "lin" => ("flow", Some("lin")), "seqd" => ("seq", Some("seqd")), "seq" => ("seq", Some("seq")), _ => (raw, None), } } fn resolve_install_owner() -> Option<String> { std::env::var("FLOW_INSTALL_OWNER") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .or_else(|| { crate::env::get_personal_env_var("FLOW_INSTALL_OWNER") .ok() .flatten() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) }) } fn resolve_github_token() -> Result<Option<String>> { for key in ["PARM_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"] { if let Ok(value) = std::env::var(key) { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(Some(trimmed.to_string())); } } } for key in [ "PARM_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "FLOW_GITHUB_TOKEN", ] { if let Ok(Some(value)) = crate::env::get_personal_env_var(key) { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(Some(trimmed.to_string())); } } } Ok(None) } fn ensure_flox_tools_env(root: &Path, packages: &[(String, FloxInstallSpec)]) -> Result<()> { let flox_bin = resolve_flox_bin()?; if flox_env_ok(&flox_bin, root).is_err() { let flox_dir = root.join(".flox"); if flox_dir.exists() { fs::remove_dir_all(&flox_dir) .with_context(|| format!("failed to remove {}", flox_dir.display()))?; } } let flox_dir = root.join(".flox"); if !flox_dir.exists() { flox_run( &flox_bin, &[ "init".to_string(), "--bare".to_string(), "-d".to_string(), root.display().to_string(), ], )?; } for (name, spec) in packages { let pkg = match spec.version.as_deref() { Some(version) if !version.trim().is_empty() => format!("{name}@{version}"), _ => name.to_string(), }; flox_run( &flox_bin, &[ "install".to_string(), "-d".to_string(), root.display().to_string(), pkg, ], )?; } if let Err(err) = flox_env_ok(&flox_bin, root) { bail!("flox env still invalid after reset: {err}"); } Ok(()) } fn resolve_flox_bin() -> Result<PathBuf> { if let Ok(path) = env::var("FLOX_BIN") { let bin = PathBuf::from(path); if bin.exists() { return Ok(bin); } } if let Ok(path) = which::which("flox") { return Ok(path); } bail!("flox not found on PATH") } fn flox_run(flox_bin: &Path, args: &[String]) -> Result<()> { let status = std::process::Command::new(flox_bin) .args(args) .status() .with_context(|| format!("failed to run flox {}", args.join(" ")))?; if !status.success() { bail!("flox {} failed", args.join(" ")); } Ok(()) } fn flox_env_ok(flox_bin: &Path, root: &Path) -> Result<()> { let output = std::process::Command::new(flox_bin) .arg("activate") .arg("-d") .arg(root) .arg("--") .arg("/bin/sh") .arg("-c") .arg("true") .output() .context("failed to run flox activate")?; if output.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&output.stderr); bail!("{}", stderr.trim()); } fn resolve_flox_pkg_name(name: &str) -> &str { match name { "jj" => "jujutsu", _ => name, } } fn prompt_flox_package() -> Result<String> { if !io::stdin().is_terminal() { bail!("package name is required (interactive search needs a TTY)"); } if which::which("fzf").is_err() { return prompt_line("Package name", None); } let query = prompt_line("Search flox for", None)?; let query = query.trim(); if query.is_empty() { bail!("package name is required"); } let entries = match typesense_config() { Some(config) => match typesense_search(&config, query) { Ok(entries) if !entries.is_empty() => entries, Ok(_) => { let flox_bin = resolve_flox_bin()?; flox_search_with_aliases(&flox_bin, query)? } Err(err) => { eprintln!("WARN typesense search failed: {err}"); let flox_bin = resolve_flox_bin()?; flox_search_with_aliases(&flox_bin, query)? } }, None => { let flox_bin = resolve_flox_bin()?; flox_search_with_aliases(&flox_bin, query)? } }; if entries.is_empty() { bail!("no flox packages found for \"{}\"", query); } let mut input = String::new(); for entry in &entries { let version = entry.version.as_deref().unwrap_or("-"); let desc = entry .description .as_deref() .filter(|d| !d.trim().is_empty()) .unwrap_or("No description"); let alias_note = entry .alias .as_deref() .map(|alias| format!(" (alias for {})", alias)) .unwrap_or_default(); input.push_str(&format!( "{}\t{}\t{}{}\n", entry.pkg_path, version, desc, alias_note )); } let mut child = std::process::Command::new("fzf") .args([ "--height=50%", "--reverse", "--delimiter=\t", "--with-nth=1,3", "--prompt=flox> ", "--preview=echo Version: {2}\\n\\n{3}", "--preview-window=right,60%,wrap", ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() .context("failed to spawn fzf")?; child .stdin .as_mut() .context("failed to open fzf stdin")? .write_all(input.as_bytes())?; let output = child.wait_with_output()?; if !output.status.success() { bail!("no package selected"); } let selection = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let selected = selection.trim().split('\t').next().unwrap_or(""); if selected.is_empty() { bail!("no package selected"); } Ok(selected.to_string()) } #[derive(Clone, Debug, Deserialize)] struct FloxSearchEntry { #[serde(rename = "pkg_path")] pkg_path: String, description: Option<String>, version: Option<String>, } #[derive(Clone, Debug)] struct FloxDisplayEntry { pkg_path: String, description: Option<String>, version: Option<String>, alias: Option<String>, } #[derive(Clone, Debug)] struct TypesenseConfig { url: String, api_key: String, collection: String, } #[derive(Debug, Deserialize)] struct TypesenseSearchResponse { hits: Vec<TypesenseHit>, } #[derive(Debug, Deserialize)] struct TypesenseHit { document: TypesenseDoc, } #[derive(Debug, Deserialize)] struct TypesenseDoc { #[serde(rename = "pkg_path")] pkg_path: String, description: Option<String>, version: Option<String>, } fn typesense_config() -> Option<TypesenseConfig> { let url = env::var("FLOW_TYPESENSE_URL").ok()?; let api_key = env::var("FLOW_TYPESENSE_API_KEY").unwrap_or_default(); let collection = env::var("FLOW_TYPESENSE_COLLECTION").unwrap_or_else(|_| "flox-packages".to_string()); Some(TypesenseConfig { url, api_key, collection, }) } fn typesense_config_with_overrides(opts: &InstallIndexOpts) -> Option<TypesenseConfig> { let url = opts .url .clone() .or_else(|| env::var("FLOW_TYPESENSE_URL").ok())?; let api_key = opts .api_key .clone() .or_else(|| env::var("FLOW_TYPESENSE_API_KEY").ok()) .unwrap_or_default(); let collection = opts.collection.clone(); Some(TypesenseConfig { url, api_key, collection, }) } fn typesense_search(config: &TypesenseConfig, query: &str) -> Result<Vec<FloxDisplayEntry>> { let client = Client::builder() .timeout(std::time::Duration::from_secs(5)) .build()?; let url = format!( "{}/collections/{}/documents/search", config.url.trim_end_matches('/'), config.collection ); let payload = serde_json::json!({ "q": query, "query_by": "pkg_path,description", "per_page": 200, }); let mut request = client.post(url).json(&payload); if !config.api_key.is_empty() { request = request.header("X-TYPESENSE-API-KEY", &config.api_key); } let response = request.send().context("failed to query typesense")?; if !response.status().is_success() { bail!("typesense returned {}", response.status()); } let body: TypesenseSearchResponse = response .json() .context("failed to parse typesense response")?; let mut entries = Vec::new(); for hit in body.hits { entries.push(FloxDisplayEntry { pkg_path: hit.document.pkg_path, description: hit.document.description, version: hit.document.version, alias: None, }); } Ok(entries) } fn typesense_ensure_collection(config: &TypesenseConfig) -> Result<()> { let client = Client::builder() .timeout(std::time::Duration::from_secs(5)) .build()?; let base = config.url.trim_end_matches('/'); let get_url = format!("{}/collections/{}", base, config.collection); let mut request = client.get(&get_url); if !config.api_key.is_empty() { request = request.header("X-TYPESENSE-API-KEY", &config.api_key); } let resp = request .send() .context("failed to check typesense collection")?; if resp.status().is_success() { return Ok(()); } if resp.status().as_u16() != 404 { bail!("typesense collection check failed ({})", resp.status()); } let create_url = format!("{}/collections", base); let schema = serde_json::json!({ "name": config.collection, "fields": [ { "name": "id", "type": "string" }, { "name": "pkg_path", "type": "string" }, { "name": "description", "type": "string", "optional": true }, { "name": "version", "type": "string", "optional": true } ], "default_sorting_field": "pkg_path" }); let mut create_req = client.post(&create_url).json(&schema); if !config.api_key.is_empty() { create_req = create_req.header("X-TYPESENSE-API-KEY", &config.api_key); } let resp = create_req .send() .context("failed to create typesense collection")?; if !resp.status().is_success() { bail!("typesense collection create failed ({})", resp.status()); } Ok(()) } fn typesense_import(config: &TypesenseConfig, entries: Vec<FloxDisplayEntry>) -> Result<()> { let client = Client::builder() .timeout(std::time::Duration::from_secs(20)) .build()?; let base = config.url.trim_end_matches('/'); let url = format!( "{}/collections/{}/documents/import?action=upsert", base, config.collection ); let mut body = String::new(); for entry in entries { let doc = serde_json::json!({ "id": entry.pkg_path, "pkg_path": entry.pkg_path, "description": entry.description, "version": entry.version }); body.push_str(&doc.to_string()); body.push('\n'); } let mut request = client.post(&url).body(body); if !config.api_key.is_empty() { request = request.header("X-TYPESENSE-API-KEY", &config.api_key); } let resp = request.send().context("failed to import into typesense")?; if !resp.status().is_success() { bail!("typesense import failed ({})", resp.status()); } Ok(()) } fn load_index_queries(query: Option<String>, path: Option<PathBuf>) -> Result<Vec<String>> { let mut out = Vec::new(); if let Some(path) = path { let content = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; for line in content.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } out.push(trimmed.to_string()); } } if let Some(query) = query { let trimmed = query.trim(); if !trimmed.is_empty() { out.push(trimmed.to_string()); } } if out.is_empty() && io::stdin().is_terminal() { let input = prompt_line("Search term to index", None)?; let trimmed = input.trim(); if !trimmed.is_empty() { out.push(trimmed.to_string()); } } Ok(out) } fn flox_search_with_aliases(flox_bin: &Path, query: &str) -> Result<Vec<FloxDisplayEntry>> { let mut seen = HashMap::<String, FloxDisplayEntry>::new(); let mut queries = vec![query.to_string()]; if let Some(extra) = flox_query_aliases(query) { queries.extend(extra.iter().map(|q| q.to_string())); } for q in queries { let results = flox_search(flox_bin, &q)?; for result in results { let entry = FloxDisplayEntry { pkg_path: result.pkg_path.clone(), description: result.description.clone(), version: result.version.clone(), alias: None, }; seen.entry(result.pkg_path).or_insert(entry); } } if let Some(alias_targets) = flox_query_aliases(query) { for alias_target in alias_targets { let alias_results = flox_search(flox_bin, alias_target)?; let picked = alias_results .iter() .find(|entry| entry.pkg_path == *alias_target) .or_else(|| alias_results.first()); if let Some(result) = picked { seen.insert( result.pkg_path.clone(), FloxDisplayEntry { pkg_path: result.pkg_path.clone(), description: result.description.clone(), version: result.version.clone(), alias: Some(query.to_string()), }, ); } } } let mut entries: Vec<_> = seen.into_values().collect(); entries.sort_by(|a, b| flox_entry_rank(a, query).cmp(&flox_entry_rank(b, query))); Ok(entries) } fn flox_query_aliases(query: &str) -> Option<&'static [&'static str]> { match query { "jj" => Some(&["jujutsu"]), _ => None, } } fn flox_entry_rank(entry: &FloxDisplayEntry, query: &str) -> (u8, String) { if entry.pkg_path == query { return (0, entry.pkg_path.clone()); } if entry.alias.is_some() { return (1, entry.pkg_path.clone()); } if entry .description .as_deref() .map(|d| d.to_ascii_lowercase().contains(&query.to_ascii_lowercase())) .unwrap_or(false) { return (2, entry.pkg_path.clone()); } (3, entry.pkg_path.clone()) } fn flox_search(flox_bin: &Path, query: &str) -> Result<Vec<FloxSearchEntry>> { let output = std::process::Command::new(flox_bin) .arg("search") .arg("--json") .arg("-a") .arg(query) .output() .with_context(|| format!("failed to run flox search {}", query))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("flox search failed: {}", stderr.trim()); } let stdout = String::from_utf8(output.stdout).context("flox search output was not valid UTF-8")?; let entries: Vec<FloxSearchEntry> = serde_json::from_str(&stdout) .with_context(|| format!("failed to parse flox search output for {}", query))?; Ok(entries) } fn prompt_line(message: &str, default: Option<&str>) -> Result<String> { if let Some(default) = default { print!("{message} [{default}]: "); } else { print!("{message}: "); } io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(default.unwrap_or("").to_string()); } Ok(trimmed.to_string()) } fn tool_root() -> Result<PathBuf> { let home = dirs::home_dir().context("failed to resolve home directory")?; Ok(home.join(".config").join("flow").join("tools")) } fn write_flox_shim(dest: &Path, env_root: &Path, bin: &str) -> Result<()> { let script = format!( "#!/bin/sh\nexec flox activate -d \"{}\" -- \"{}\" \"$@\"\n", env_root.display(), bin ); fs::write(dest, script).with_context(|| format!("failed to write {}", dest.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(dest)?.permissions(); perms.set_mode(0o755); fs::set_permissions(dest, perms)?; } Ok(()) } fn shim_matches(dest: &Path, env_root: &Path, bin: &str) -> Result<bool> { let content = fs::read_to_string(dest).with_context(|| format!("failed to read {}", dest.display()))?; let expected = format!( "#!/bin/sh\nexec flox activate -d \"{}\" -- \"{}\" \"$@\"\n", env_root.display(), bin ); Ok(content == expected) } fn prompt_overwrite(path: &Path) -> Result<bool> { if !io::stdin().is_terminal() { return Ok(false); } print!("{} already exists. Overwrite? [y/N]: ", path.display()); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); Ok(answer == "y" || answer == "yes") } fn default_bin_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let local = home.join(".local").join("bin"); if local.exists() { return local; } let bin = home.join("bin"); if bin.exists() { return bin; } local } fn path_in_env(bin_dir: &Path) -> bool { let Ok(path) = env::var("PATH") else { return false; }; env::split_paths(&path).any(|entry| entry == bin_dir) } ================================================ FILE: src/invariants.rs ================================================ //! Standalone invariant checking for projects. //! //! Reads [invariants] from flow.toml and checks the working tree or staged diff. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result}; use crate::config::{self, InvariantsConfig}; /// A single invariant finding. #[derive(Debug)] pub struct Finding { pub severity: String, pub category: String, pub message: String, pub file: Option<String>, } /// Result of running invariant checks. #[derive(Debug)] pub struct Report { pub findings: Vec<Finding>, pub invariants_loaded: bool, pub mode: String, } /// Check project invariants against the working tree. /// If `staged_only` is true, checks only staged (cached) diff. pub fn check(root: &Path, staged_only: bool) -> Result<Report> { let cfg = config::load_or_default(root.join("flow.toml")); let Some(inv) = cfg.invariants else { println!("No [invariants] section in flow.toml"); return Ok(Report { findings: Vec::new(), invariants_loaded: false, mode: "off".to_string(), }); }; let mode = inv.mode.as_deref().unwrap_or("warn").to_ascii_lowercase(); if mode == "off" { println!("Invariants are disabled (mode=off)."); return Ok(Report { findings: Vec::new(), invariants_loaded: true, mode, }); } let mut findings = Vec::new(); // Get diff. let diff_args = if staged_only { vec!["diff", "--cached"] } else { vec!["diff", "HEAD"] }; let diff = git_capture(root, &diff_args).unwrap_or_default(); let changed_files = changed_files_from_diff(&diff); // 1. Forbidden patterns in diff. check_forbidden_patterns(&inv, &diff, &mut findings); // 2. Dependency policy. if let Some(deps_config) = &inv.deps { let policy = deps_config.policy.as_deref().unwrap_or("approval_required"); if policy == "approval_required" && !deps_config.approved.is_empty() { check_deps(root, &changed_files, &deps_config.approved, &mut findings); } } // 3. File size limits. if let Some(files_config) = &inv.files { if let Some(max_lines) = files_config.max_lines { check_file_sizes(root, &changed_files, max_lines, &mut findings); } } // Print results. print_report(&inv, &findings); let has_blocking = findings .iter() .any(|f| f.severity == "critical" || f.severity == "warning"); if mode == "block" && has_blocking { anyhow::bail!( "Invariant violations found (mode=block): {} finding(s)", findings.len() ); } Ok(Report { findings, invariants_loaded: true, mode, }) } fn check_forbidden_patterns(inv: &InvariantsConfig, diff: &str, findings: &mut Vec<Finding>) { // Skip flow.toml itself — it contains the forbidden list definitions. let skip_files = ["flow.toml"]; for pattern in &inv.forbidden { let pat_lower = pattern.to_lowercase(); let mut current_file: Option<String> = None; let mut skip_current = false; for line in diff.lines() { if line.starts_with("+++ b/") { let file = line .strip_prefix("+++ b/") .unwrap_or("") .trim() .trim_matches('"'); current_file = Some(file.to_string()); skip_current = skip_files.iter().any(|s| file.ends_with(s)); continue; } if current_file .as_deref() .is_some_and(|f| f.trim().trim_matches('"').ends_with("flow.toml")) { continue; } if skip_current { continue; } if !line.starts_with('+') || line.starts_with("+++") { continue; } if line.to_lowercase().contains(&pat_lower) { findings.push(Finding { severity: "warning".to_string(), category: "forbidden".to_string(), message: format!("Forbidden pattern '{}' found", pattern), file: current_file.clone(), }); break; } } } } fn check_deps( root: &Path, changed_files: &[String], approved: &[String], findings: &mut Vec<Finding>, ) { // Check all package.json files in repo, not just changed ones. let pkg_files: Vec<PathBuf> = if changed_files.iter().any(|f| f.ends_with("package.json")) { changed_files .iter() .filter(|f| f.ends_with("package.json")) .map(|f| root.join(f)) .collect() } else { // Also check existing package.json for a full health scan. find_package_jsons(root) }; for pkg_path in pkg_files { let Ok(contents) = fs::read_to_string(&pkg_path) else { continue; }; let rel = pkg_path .strip_prefix(root) .unwrap_or(&pkg_path) .display() .to_string(); check_unapproved_deps(&contents, approved, &rel, findings); } } fn check_unapproved_deps( package_json: &str, approved: &[String], file_path: &str, findings: &mut Vec<Finding>, ) { let Ok(parsed) = serde_json::from_str::<serde_json::Value>(package_json) else { return; }; let dep_sections = ["dependencies", "devDependencies", "peerDependencies"]; for section in &dep_sections { if let Some(deps) = parsed.get(section).and_then(|v| v.as_object()) { for dep_name in deps.keys() { if !approved.iter().any(|a| a == dep_name) { findings.push(Finding { severity: "warning".to_string(), category: "deps".to_string(), message: format!("'{}' ({}) not on approved list", dep_name, section), file: Some(file_path.to_string()), }); } } } } } fn check_file_sizes( root: &Path, changed_files: &[String], max_lines: u32, findings: &mut Vec<Finding>, ) { for file in changed_files { let full = root.join(file); if let Ok(contents) = fs::read_to_string(&full) { let line_count = contents.lines().count() as u32; if line_count > max_lines { findings.push(Finding { severity: "warning".to_string(), category: "files".to_string(), message: format!("{} lines (max {})", line_count, max_lines), file: Some(file.clone()), }); } } } } fn find_package_jsons(root: &Path) -> Vec<PathBuf> { let mut result = Vec::new(); let root_pkg = root.join("package.json"); if root_pkg.exists() { result.push(root_pkg); } // Check common subdirs. for subdir in &["api/ts", "web", "packages"] { let pkg = root.join(subdir).join("package.json"); if pkg.exists() { result.push(pkg); } } result } fn print_report(inv: &InvariantsConfig, findings: &[Finding]) { println!("Invariants loaded from flow.toml\n"); if let Some(style) = inv.architecture_style.as_deref() { println!(" Architecture: {}", style); } if !inv.non_negotiable.is_empty() { println!(" Non-negotiable rules: {}", inv.non_negotiable.len()); } if !inv.forbidden.is_empty() { println!(" Forbidden patterns: {}", inv.forbidden.len()); } if !inv.terminology.is_empty() { println!(" Terminology terms: {}", inv.terminology.len()); } if let Some(deps) = &inv.deps { println!(" Approved deps: {}", deps.approved.len()); } if let Some(files) = &inv.files { if let Some(max) = files.max_lines { println!(" Max lines per file: {}", max); } } println!(); if findings.is_empty() { println!("No findings."); return; } let warnings = findings.iter().filter(|f| f.severity == "warning").count(); let notes = findings.iter().filter(|f| f.severity == "note").count(); let criticals = findings.iter().filter(|f| f.severity == "critical").count(); println!( "Findings: {} critical, {} warning, {} note\n", criticals, warnings, notes ); for f in findings { let icon = match f.severity.as_str() { "critical" => "!!", "warning" => "!", _ => "i", }; let loc = f.file.as_deref().unwrap_or("(repo)"); println!(" [{}:{}] {} — {}", icon, f.category, loc, f.message); } } fn changed_files_from_diff(diff: &str) -> Vec<String> { let mut files = Vec::new(); for line in diff.lines() { if let Some(path) = line.strip_prefix("+++ b/") { if path != "/dev/null" { files.push(path.to_string()); } } } files.sort(); files.dedup(); files } fn git_capture(workdir: &Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .current_dir(workdir) .args(args) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; #[test] fn forbidden_scan_ignores_flow_toml_lines() { let inv = InvariantsConfig { forbidden: vec!["useState(".to_string()], terminology: HashMap::new(), ..Default::default() }; let diff = r#"diff --git a/flow.toml b/flow.toml +++ b/flow.toml +forbidden = ["useState("] diff --git a/web/app.tsx b/web/app.tsx +++ b/web/app.tsx +const x = useState(0) "#; let mut findings = Vec::new(); check_forbidden_patterns(&inv, diff, &mut findings); assert_eq!(findings.len(), 1); assert_eq!(findings[0].file.as_deref(), Some("web/app.tsx")); } #[test] fn dep_scan_marks_unapproved_as_warning() { let pkg = r#"{ "dependencies": { "react": "^18.0.0", "@reatom/core": "^3.0.0" } }"#; let approved = vec!["@reatom/core".to_string()]; let mut findings = Vec::new(); check_unapproved_deps(pkg, &approved, "package.json", &mut findings); assert_eq!(findings.len(), 1); assert_eq!(findings[0].severity, "warning"); assert!(findings[0].message.contains("react")); } } ================================================ FILE: src/jazz_state.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::Duration; use anyhow::{Context, Result}; use futures::executor::block_on; use groove::ObjectId; use groove::sql::{Database, DatabaseError}; use groove_rocksdb::RocksEnvironment; use crate::{config, history::InvocationRecord}; const CATALOG_ID_FILE: &str = "catalog.id"; const DEFAULT_DB_DIR: &str = ".config/flow/jazz2"; const DEFAULT_REPO_ROOT: &str = "~/repos/garden-co/jazz2"; const OUTPUT_LIMIT: usize = 80_000; static DB: OnceLock<Mutex<Option<Database>>> = OnceLock::new(); pub fn record_task_run(record: &InvocationRecord) -> Result<()> { if env_flag("FLOW_JAZZ2_DISABLE") { return Ok(()); } if let Err(err) = with_db(|db| { ensure_schema(db).context("ensure jazz2 schema")?; insert_task_run(db, record).context("insert task run")?; Ok(()) }) { if is_lock_error(&err) { return Ok(()); } return Err(err); } Ok(()) } fn with_db<F>(op: F) -> Result<()> where F: FnOnce(&Database) -> Result<()>, { let mutex = DB.get_or_init(|| Mutex::new(None)); let mut guard = mutex.lock().expect("jazz2 db mutex poisoned"); if guard.is_none() { *guard = Some(open_db_with_retry().context("open jazz2 state db")?); } let db = guard.as_ref().expect("jazz2 db missing after init"); op(db) } fn env_flag(name: &str) -> bool { std::env::var(name) .ok() .map(|value| { matches!( value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ) }) .unwrap_or(false) } fn open_db() -> Result<Database> { use groove::Environment; let path = state_dir(); fs::create_dir_all(&path).with_context(|| format!("create jazz2 dir {}", path.display()))?; let env: Arc<dyn Environment> = Arc::new(RocksEnvironment::open(&path).context("open rocksdb env")?); if let Some(catalog_id) = load_catalog_id(&path)? { if let Ok(db) = block_on(Database::from_env(Arc::clone(&env), catalog_id)) { return Ok(db); } } let db = Database::new(env); save_catalog_id(&path, db.catalog_object_id())?; Ok(db) } fn open_db_with_retry() -> Result<Database> { let mut last_err: Option<anyhow::Error> = None; for _ in 0..3 { match open_db() { Ok(db) => return Ok(db), Err(err) => { last_err = Some(err); thread::sleep(Duration::from_millis(60)); } } } Err(last_err.unwrap_or_else(|| anyhow::anyhow!("open jazz2 failed"))) } fn is_lock_error(err: &anyhow::Error) -> bool { err.chain().any(|cause| { let msg = cause.to_string().to_lowercase(); msg.contains("lock") || msg.contains("resource temporarily unavailable") }) } pub fn state_dir() -> PathBuf { if let Ok(path) = std::env::var("FLOW_JAZZ2_PATH") { return config::expand_path(&path); } let repo_root = config::expand_path(DEFAULT_REPO_ROOT); if repo_root.exists() { return repo_root.join(".jazz2"); } std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(DEFAULT_DB_DIR) } fn load_catalog_id(base: &Path) -> Result<Option<ObjectId>> { let path = base.join(CATALOG_ID_FILE); if !path.exists() { return Ok(None); } let contents = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let trimmed = contents.trim(); if trimmed.is_empty() { return Ok(None); } let id = trimmed .parse::<ObjectId>() .with_context(|| format!("parse catalog id {}", trimmed))?; Ok(Some(id)) } fn save_catalog_id(base: &Path, id: ObjectId) -> Result<()> { let path = base.join(CATALOG_ID_FILE); fs::write(&path, id.to_string()).with_context(|| format!("write {}", path.display())) } fn ensure_schema(db: &Database) -> Result<()> { let sql = r#" CREATE TABLE flow_task_runs ( project_root STRING NOT NULL, project_name STRING, config_path STRING NOT NULL, task STRING NOT NULL, command STRING NOT NULL, user_input STRING NOT NULL, success BOOL NOT NULL, status I64, duration_ms I64 NOT NULL, timestamp_ms I64 NOT NULL, flow_version STRING NOT NULL, used_flox BOOL NOT NULL, output STRING NOT NULL ) "#; match db.execute(sql) { Ok(_) => Ok(()), Err(DatabaseError::TableExists(_)) => Ok(()), Err(err) => Err(anyhow::anyhow!("create table failed: {:?}", err)), } } fn insert_task_run(db: &Database, record: &InvocationRecord) -> Result<()> { let status = record .status .map(|value| value.to_string()) .unwrap_or_else(|| "NULL".to_string()); let duration_ms = record.duration_ms.min(i64::MAX as u128) as i64; let timestamp_ms = record.timestamp_ms.min(i64::MAX as u128) as i64; let project_name = record .project_name .as_ref() .map(|value| format!("'{}'", sql_escape(value))) .unwrap_or_else(|| "NULL".to_string()); let output = truncate_output(&record.output, OUTPUT_LIMIT); let sql = format!( "INSERT INTO flow_task_runs \ (project_root, project_name, config_path, task, command, user_input, success, status, \ duration_ms, timestamp_ms, flow_version, used_flox, output) \ VALUES ('{}', {}, '{}', '{}', '{}', '{}', {}, {}, {}, {}, '{}', {}, '{}')", sql_escape(&record.project_root), project_name, sql_escape(&record.config_path), sql_escape(&record.task_name), sql_escape(&record.command), sql_escape(&record.user_input), if record.success { "true" } else { "false" }, status, duration_ms, timestamp_ms, sql_escape(&record.flow_version), if record.used_flox { "true" } else { "false" }, sql_escape(&output), ); db.execute(&sql) .map(|_| ()) .map_err(|err| anyhow::anyhow!("insert failed: {:?}", err)) } fn sql_escape(value: &str) -> String { // Replace single quotes with backticks since groove SQL doesn't handle '' escaping well // Also remove null bytes value.replace('\'', "`").replace('\0', "") } fn truncate_output(value: &str, limit: usize) -> String { if value.len() <= limit { return value.to_string(); } let mut start = value.len() - limit; while start < value.len() && !value.is_char_boundary(start) { start += 1; } let mut truncated = String::from("... "); truncated.push_str(&value[start..]); truncated } ================================================ FILE: src/jazz_state_stub.rs ================================================ use anyhow::Result; use crate::base_tool; use crate::config; use crate::history::InvocationRecord; const DEFAULT_DB_DIR: &str = ".config/flow/jazz2"; const DEFAULT_REPO_ROOT: &str = "~/repos/garden-co/jazz2"; pub fn record_task_run(record: &InvocationRecord) -> Result<()> { // Best-effort: never fail the parent task run if base isn't installed or errors out. let Some(bin) = base_tool::resolve_bin() else { return Ok(()); }; let Ok(payload) = serde_json::to_string(record) else { return Ok(()); }; let args: Vec<String> = vec!["ingest".to_string(), "task-run".to_string()]; let _ = base_tool::run_with_stdin(&bin, &args, &payload); Ok(()) } pub fn state_dir() -> std::path::PathBuf { if let Ok(path) = std::env::var("FLOW_JAZZ2_PATH") { return config::expand_path(&path); } let repo_root = config::expand_path(DEFAULT_REPO_ROOT); if repo_root.exists() { return repo_root.join(".jazz2"); } std::env::var_os("HOME") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(DEFAULT_DB_DIR) } ================================================ FILE: src/jj.rs ================================================ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::cli::{ JjAction, JjBookmarkAction, JjCommand, JjPushOpts, JjRebaseOpts, JjStatusOpts, JjSyncOpts, JjWorkspaceAction, }; use crate::config; use crate::vcs; struct JjContext { workspace_root: PathBuf, repo_root: PathBuf, } pub fn run(cmd: JjCommand) -> Result<()> { match cmd .action .unwrap_or(JjAction::Status(JjStatusOpts::default())) { JjAction::Init { path } => run_init(path), JjAction::Status(opts) => run_status(opts), JjAction::Fetch => run_fetch(), JjAction::Rebase(opts) => run_rebase(opts), JjAction::Push(opts) => run_push(opts), JjAction::Sync(opts) => run_sync(opts), JjAction::Workspace(action) => run_workspace(action), JjAction::Bookmark(action) => run_bookmark(action), } } pub fn run_workflow_status(raw: bool) -> Result<()> { run_status(JjStatusOpts { raw }) } fn run_init(path: Option<PathBuf>) -> Result<()> { vcs::ensure_jj_installed()?; let root = path.unwrap_or(std::env::current_dir().context("failed to read current dir")?); let root = root.canonicalize().unwrap_or(root); if is_jj_repo(&root) { println!("JJ already initialized at {}", root.display()); return Ok(()); } let has_git = root.join(".git").exists(); if has_git { jj_run_in(&root, &["git", "init", "--colocate"])?; } else { jj_run_in(&root, &["git", "init"])?; } let repo_root = vcs::ensure_jj_repo_in(&root)?; let branch = default_branch(&repo_root); let remote = default_remote(&repo_root); let auto_track = auto_track_enabled(&repo_root); if jj_run_in(&repo_root, &["git", "fetch"]).is_err() { println!("⚠ jj git fetch failed (no remote yet?)"); return Ok(()); } if auto_track { let track_ref = format!("{}@{}", branch, remote); if jj_run_in(&repo_root, &["bookmark", "track", &track_ref]).is_err() { println!("⚠ Failed to track {}", track_ref); } } println!("✓ JJ initialized (colocated: {})", has_git); Ok(()) } fn current_context() -> Result<JjContext> { let workspace_root = vcs::ensure_jj_repo()?; let repo_root = repo_root_for_workspace(&workspace_root)?; Ok(JjContext { workspace_root, repo_root, }) } fn repo_root_for_workspace(workspace_root: &Path) -> Result<PathBuf> { let git_root = jj_capture_in(workspace_root, &["git", "root"])?; repo_root_from_git_root(git_root.trim()).map(PathBuf::from) } fn repo_root_from_git_root(git_root: &str) -> Result<&str> { let trimmed = git_root.trim(); if trimmed.is_empty() { bail!("jj git root returned an empty path"); } if let Some(parent) = trimmed.strip_suffix("/.git") { return Ok(parent); } if let Some(parent) = trimmed.strip_suffix("\\.git") { return Ok(parent); } Ok(trimmed) } fn run_status(opts: JjStatusOpts) -> Result<()> { let ctx = current_context()?; if opts.raw { return jj_run_in(&ctx.workspace_root, &["status"]); } let snapshot = collect_status_snapshot(&ctx)?; print_status_snapshot(&snapshot); Ok(()) } fn run_fetch() -> Result<()> { let ctx = current_context()?; ensure_git_not_busy(&ctx.repo_root)?; jj_run_in(&ctx.workspace_root, &["git", "fetch"]) } fn run_rebase(opts: JjRebaseOpts) -> Result<()> { let ctx = current_context()?; ensure_git_not_busy(&ctx.repo_root)?; let remote = default_remote(&ctx.repo_root); let dest = opts.dest.unwrap_or_else(|| default_branch(&ctx.repo_root)); let target = resolve_rebase_target(&ctx.workspace_root, &dest, &remote); jj_run_in(&ctx.workspace_root, &["rebase", "-d", &target]) } fn run_push(opts: JjPushOpts) -> Result<()> { let ctx = current_context()?; ensure_git_not_busy(&ctx.repo_root)?; if opts.all { return jj_run_in(&ctx.workspace_root, &["git", "push", "--all"]); } let Some(bookmark) = opts.bookmark else { bail!("Specify a bookmark or pass --all"); }; jj_run_in( &ctx.workspace_root, &["git", "push", "--bookmark", &bookmark], ) } fn run_sync(opts: JjSyncOpts) -> Result<()> { let ctx = current_context()?; ensure_git_not_busy(&ctx.repo_root)?; let remote = opts .remote .unwrap_or_else(|| default_remote(&ctx.repo_root)); let dest = opts.dest.unwrap_or_else(|| default_branch(&ctx.repo_root)); jj_run_in(&ctx.workspace_root, &["git", "fetch"])?; let target = resolve_rebase_target(&ctx.workspace_root, &dest, &remote); jj_run_in(&ctx.workspace_root, &["rebase", "-d", &target])?; // Check for conflicts after rebase let has_conflicts = jj_capture_in( &ctx.workspace_root, &["log", "-r", "conflicts()", "--no-graph", "-T", "commit_id"], ) .map(|out| !out.trim().is_empty()) .unwrap_or(false); if has_conflicts { let details = jj_capture_in( &ctx.workspace_root, &["log", "-r", "conflicts()", "--no-graph"], ) .unwrap_or_default(); eprintln!("\n⚠ Rebase produced conflicts:"); for line in details.lines().filter(|l| !l.trim().is_empty()) { eprintln!(" {}", line.trim()); } eprintln!("\nResolve with: jj resolve"); } if opts.no_push { return Ok(()); } let Some(bookmark) = opts.bookmark else { return Ok(()); }; jj_run_in( &ctx.workspace_root, &["git", "push", "--bookmark", &bookmark], ) } fn run_workspace(action: JjWorkspaceAction) -> Result<()> { let ctx = current_context()?; match action { JjWorkspaceAction::List => jj_run_in(&ctx.workspace_root, &["workspace", "list"]), JjWorkspaceAction::Add { name, path, rev } => { let workspace_path = match path { Some(p) => p, None => workspace_default_path(&ctx.repo_root, &name)?, }; run_workspace_add(&ctx.workspace_root, &name, workspace_path, rev.as_deref()) } JjWorkspaceAction::Lane { name, path, base, remote, no_fetch, } => { ensure_git_not_busy(&ctx.repo_root)?; let remote = remote.unwrap_or_else(|| default_remote(&ctx.repo_root)); if !no_fetch { if let Err(err) = jj_run_in(&ctx.workspace_root, &["git", "fetch"]) { eprintln!("⚠ jj git fetch failed: {err}"); eprintln!(" continuing with current local refs"); } } let workspace_path = match path { Some(p) => p, None => workspace_default_path(&ctx.repo_root, &name)?, }; let base_rev = base.unwrap_or_else(|| { let dest = default_branch(&ctx.repo_root); resolve_rebase_target(&ctx.workspace_root, &dest, &remote) }); run_workspace_add( &ctx.workspace_root, &name, workspace_path.clone(), Some(&base_rev), )?; println!("Lane {} is anchored at {}", name, base_rev); println!("Next: cd {}", workspace_path.display()); println!( "Optional bookmark: f jj bookmark create {} --rev @ --track --remote {}", name, remote ); Ok(()) } JjWorkspaceAction::Review { branch, path, base, remote, no_fetch, } => { let remote = remote.unwrap_or_else(|| default_remote(&ctx.repo_root)); if !no_fetch { ensure_git_not_busy(&ctx.repo_root)?; if let Err(err) = jj_run_in(&ctx.workspace_root, &["git", "fetch"]) { eprintln!("⚠ jj git fetch failed: {err}"); eprintln!(" continuing with current local refs"); } } let workspace_name = review_workspace_name(&branch); if workspace_name.is_empty() { bail!("Invalid review branch name: {}", branch); } let workspace_path = match path { Some(p) => p, None => workspace_default_path(&ctx.repo_root, &workspace_name)?, }; if let Some(existing_path) = existing_workspace_path(&ctx.workspace_root, &ctx.repo_root, &workspace_name)? { if existing_path != workspace_path { bail!( "Workspace {} already exists at {}", workspace_name, existing_path.display() ); } println!( "Reusing review workspace {} at {}", workspace_name, existing_path.display() ); } else { let resolution = resolve_review_workspace_base( &ctx.repo_root, &branch, &remote, base.as_deref(), ); run_workspace_add( &ctx.workspace_root, &workspace_name, workspace_path.clone(), Some(&resolution.rev), )?; println!( "Review branch {} resolved via {}", branch, resolution.source ); } println!("Next: cd {}", workspace_path.display()); println!( "Use `jj` / `f jj` inside this workspace. Git commands still point at the colocated main checkout." ); println!( "When you are ready to publish from JJ: f jj bookmark create {} --rev @ --track --remote {}", branch, remote ); Ok(()) } } } fn run_workspace_add( repo_root: &Path, name: &str, workspace_path: PathBuf, rev: Option<&str>, ) -> Result<()> { if let Some(parent) = workspace_path.parent() { std::fs::create_dir_all(parent)?; } let path_str = workspace_path .to_str() .ok_or_else(|| anyhow::anyhow!("invalid workspace path"))? .to_string(); let args = workspace_add_args(&path_str, name, rev); jj_run_owned_in(repo_root, &args)?; if let Some(rev) = rev.filter(|v| !v.trim().is_empty()) { println!( "Created workspace {} at {} (base: {})", name, workspace_path.display(), rev.trim() ); } else { println!("Created workspace {} at {}", name, workspace_path.display()); } Ok(()) } fn workspace_add_args(destination: &str, name: &str, rev: Option<&str>) -> Vec<String> { let mut args = vec![ "workspace".to_string(), "add".to_string(), destination.to_string(), "--name".to_string(), name.to_string(), ]; if let Some(rev) = rev { let trimmed = rev.trim(); if !trimmed.is_empty() { args.push("--revision".to_string()); args.push(trimmed.to_string()); } } args } fn run_bookmark(action: JjBookmarkAction) -> Result<()> { let ctx = current_context()?; match action { JjBookmarkAction::List => jj_run_in(&ctx.workspace_root, &["bookmark", "list"]), JjBookmarkAction::Track { name, remote } => { let remote = remote.unwrap_or_else(|| default_remote(&ctx.repo_root)); let track_ref = format!("{}@{}", name, remote); jj_run_in(&ctx.workspace_root, &["bookmark", "track", &track_ref]) } JjBookmarkAction::Create { name, rev, track, remote, } => { let rev = rev.unwrap_or_else(|| "@".to_string()); jj_run_in( &ctx.workspace_root, &["bookmark", "create", &name, "-r", &rev], )?; let should_track = track.unwrap_or_else(|| auto_track_enabled(&ctx.repo_root)); if should_track { let remote = remote.unwrap_or_else(|| default_remote(&ctx.repo_root)); let track_ref = format!("{}@{}", name, remote); if jj_run_in(&ctx.workspace_root, &["bookmark", "track", &track_ref]).is_err() { println!("⚠ Failed to track {}", track_ref); } } Ok(()) } } } struct WorkflowStatusSnapshot { workspace_root: PathBuf, repo_root: PathBuf, workspace_name: String, current_ref: String, current_role: &'static str, home_branch: String, intake_branch: String, remote: String, trunk_ref: String, home_unique_to_trunk: usize, trunk_unique_to_home: usize, leaves: Vec<LeafBranchStatus>, workspaces: Vec<WorkspaceStatus>, working_copy_lines: Vec<String>, } struct LeafBranchStatus { name: String, kind: &'static str, unique_commits: usize, tracked_remote: bool, workspace_name: Option<String>, is_current: bool, } struct WorkspaceStatus { name: String, is_current: bool, path_exists: bool, } fn collect_status_snapshot(ctx: &JjContext) -> Result<WorkflowStatusSnapshot> { let default_branch = default_branch(&ctx.repo_root); let remote = default_remote(&ctx.repo_root); let workspace_name = current_workspace_name(&ctx.workspace_root)?; let current_bookmarks = local_bookmarks_at_rev(&ctx.workspace_root, "@"); let parent_bookmarks = local_bookmarks_at_rev(&ctx.workspace_root, "@-"); let all_bookmarks = jj_bookmark_names(&ctx.workspace_root)?; let all_local_bookmarks = all_bookmarks .iter() .filter(|name| !name.contains('@')) .cloned() .collect::<HashSet<_>>(); let current_git_branch = git_current_branch(&ctx.repo_root).unwrap_or_default(); let home_branch = infer_home_branch( &ctx.repo_root, &default_branch, ¤t_bookmarks, &parent_bookmarks, &all_local_bookmarks, ); let intake_branch = derive_intake_branch(&home_branch); let current_ref = infer_current_ref( &workspace_name, ¤t_git_branch, &home_branch, &intake_branch, &default_branch, ¤t_bookmarks, &parent_bookmarks, ); let current_role = classify_branch_role(¤t_ref, &home_branch, &intake_branch); let remote_trunk_ref = format!("{default_branch}@{remote}"); let trunk_ref = if all_bookmarks.contains(&remote_trunk_ref) { remote_trunk_ref } else { default_branch.clone() }; let leaf_names: Vec<String> = all_local_bookmarks .iter() .filter(|name| is_leaf_branch(name)) .cloned() .collect(); let workspace_names = jj_workspace_names(&ctx.workspace_root)?; let mut leaves = Vec::new(); for name in leaf_names { let workspace_name_for_branch = workspace_name_for_branch(&name) .filter(|candidate| workspace_names.contains(candidate)); leaves.push(LeafBranchStatus { kind: leaf_branch_kind(&name), unique_commits: count_unique_commits(&ctx.workspace_root, &name, &home_branch), tracked_remote: all_bookmarks.contains(&format!("{name}@{remote}")), workspace_name: workspace_name_for_branch, is_current: name == current_ref, name, }); } leaves.sort_by(|left, right| left.name.cmp(&right.name)); let mut workspaces: Vec<WorkspaceStatus> = workspace_names .into_iter() .map(|name| WorkspaceStatus { path_exists: inferred_workspace_path(&ctx.repo_root, &name).exists(), is_current: name == workspace_name, name, }) .collect(); workspaces.sort_by(|left, right| left.name.cmp(&right.name)); let working_copy_output = jj_capture_in(&ctx.workspace_root, &["status"])?; Ok(WorkflowStatusSnapshot { workspace_root: ctx.workspace_root.clone(), repo_root: ctx.repo_root.clone(), workspace_name, current_ref, current_role, home_branch: home_branch.clone(), intake_branch, remote, trunk_ref: trunk_ref.clone(), home_unique_to_trunk: count_unique_commits(&ctx.workspace_root, &home_branch, &trunk_ref), trunk_unique_to_home: count_unique_commits(&ctx.workspace_root, &trunk_ref, &home_branch), leaves, workspaces, working_copy_lines: working_copy_output .lines() .map(|line| line.to_string()) .collect(), }) } fn print_status_snapshot(snapshot: &WorkflowStatusSnapshot) { println!("JJ Workflow Status"); println!(); println!("Repo: {}", snapshot.repo_root.display()); println!( "Workspace: {} ({})", snapshot.workspace_name, snapshot.workspace_root.display() ); println!( "Current: {} [{}]", snapshot.current_ref, snapshot.current_role ); println!( "Home: {} ({} commit(s) not in {})", snapshot.home_branch, snapshot.home_unique_to_trunk, snapshot.trunk_ref ); println!("Intake: {}", snapshot.intake_branch); println!( "Trunk: {} ({} commit(s) not in {})", snapshot.trunk_ref, snapshot.trunk_unique_to_home, snapshot.home_branch ); println!("Remote: {}", snapshot.remote); println!(); println!("Leaf Branches:"); if snapshot.leaves.is_empty() { println!(" none"); } else { for leaf in &snapshot.leaves { let current = if leaf.is_current { " current" } else { "" }; let tracked = if leaf.tracked_remote { format!(" tracked@{}", snapshot.remote) } else { " local-only".to_string() }; let workspace = leaf .workspace_name .as_deref() .map(|name| format!(" workspace={name}")) .unwrap_or_default(); println!( " {} [{}] {} commit(s) over {}{}{}{}", leaf.name, leaf.kind, leaf.unique_commits, snapshot.home_branch, tracked, workspace, current ); } } println!(); println!("Workspaces:"); for workspace in &snapshot.workspaces { let marker = if workspace.is_current { "*" } else { "-" }; let suffix = if workspace.path_exists { "" } else { " (expected path missing)" }; println!(" {} {}{}", marker, workspace.name, suffix); } println!(); println!("Working Copy:"); for line in &snapshot.working_copy_lines { println!(" {}", line); } println!(); println!("Suggested Next:"); if snapshot.current_role == "review" || snapshot.current_role == "codex" { println!(" f jj push --bookmark {}", snapshot.current_ref); println!( " jj rebase -b {} -o {}", snapshot.current_ref, snapshot.home_branch ); } else { println!(" f jj sync --bookmark {}", snapshot.home_branch); println!( " f jj workspace review review/{}-topic", snapshot.home_branch ); } } fn current_workspace_name(workspace_root: &Path) -> Result<String> { let output = jj_capture_in( workspace_root, &["log", "-r", "@", "--no-graph", "-T", "working_copies"], )?; let trimmed = output.trim().trim_end_matches('@').trim(); if trimmed.is_empty() { Ok("default".to_string()) } else { Ok(trimmed.to_string()) } } fn configured_home_branch(repo_root: &Path) -> Option<String> { load_jj_config(repo_root) .and_then(|cfg| cfg.home_branch) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn git_current_branch(repo_root: &Path) -> Option<String> { let output = Command::new("git") .current_dir(repo_root) .args(["branch", "--show-current"]) .output() .ok()?; if !output.status.success() { return None; } let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); if value.is_empty() { None } else { Some(value) } } fn infer_home_branch( repo_root: &Path, default_branch: &str, current_bookmarks: &[String], parent_bookmarks: &[String], all_bookmarks: &HashSet<String>, ) -> String { if let Some(home_branch) = configured_home_branch(repo_root) { return home_branch; } if let Some(git_branch) = git_current_branch(repo_root).filter(|name| is_home_branch_candidate(name, default_branch)) { return git_branch; } for bookmark in current_bookmarks .iter() .chain(parent_bookmarks.iter()) .chain(all_bookmarks.iter()) { if is_home_branch_candidate(bookmark, default_branch) { return bookmark.clone(); } } default_branch.to_string() } fn derive_intake_branch(home_branch: &str) -> String { if home_branch.trim().is_empty() || home_branch == "main" { "main-intake".to_string() } else { format!("{home_branch}-main-intake") } } fn infer_current_ref( workspace_name: &str, current_git_branch: &str, home_branch: &str, intake_branch: &str, default_branch: &str, current_bookmarks: &[String], parent_bookmarks: &[String], ) -> String { let candidates: Vec<String> = current_bookmarks .iter() .chain(parent_bookmarks.iter()) .cloned() .collect(); if let Some(match_by_workspace) = candidates .iter() .find(|name| workspace_name_for_branch(name).as_deref() == Some(workspace_name)) { return match_by_workspace.clone(); } if workspace_name == "default" && !current_git_branch.is_empty() && !is_leaf_branch(current_git_branch) { return current_git_branch.to_string(); } if let Some(leaf) = candidates.iter().find(|name| is_leaf_branch(name)) { return leaf.clone(); } if candidates.iter().any(|name| name == home_branch) { return home_branch.to_string(); } if candidates.iter().any(|name| name == intake_branch) { return intake_branch.to_string(); } if let Some(plain) = candidates .iter() .find(|name| is_home_branch_candidate(name, default_branch)) { return plain.clone(); } if !home_branch.is_empty() { return home_branch.to_string(); } default_branch.to_string() } fn classify_branch_role(name: &str, home_branch: &str, intake_branch: &str) -> &'static str { if name == home_branch { "home" } else if name == intake_branch { "intake" } else if name.starts_with("review/") { "review" } else if name.starts_with("codex/") { "codex" } else { "other" } } fn is_home_branch_candidate(name: &str, default_branch: &str) -> bool { !name.trim().is_empty() && !is_leaf_branch(name) && !is_intake_branch(name) && name != default_branch && !name.contains('@') } fn is_intake_branch(name: &str) -> bool { name == "main-intake" || name.ends_with("-main-intake") } fn is_leaf_branch(name: &str) -> bool { name.starts_with("review/") || name.starts_with("codex/") } fn leaf_branch_kind(name: &str) -> &'static str { if name.starts_with("review/") { "review" } else { "codex" } } fn workspace_name_for_branch(name: &str) -> Option<String> { let value = review_workspace_name(name); if value.is_empty() { None } else { Some(value) } } fn local_bookmarks_at_rev(workspace_root: &Path, rev: &str) -> Vec<String> { let output = jj_capture_in( workspace_root, &["log", "-r", rev, "--no-graph", "-T", "bookmarks"], ) .unwrap_or_default(); parse_bookmark_tokens(&output) } fn jj_bookmark_names(workspace_root: &Path) -> Result<HashSet<String>> { let output = jj_capture_in( workspace_root, &[ "bookmark", "list", "--all-remotes", "-T", "if(remote, name ++ \"@\" ++ remote, name) ++ \"\\n\"", ], )?; Ok(parse_bookmark_list_names(&output) .into_iter() .collect::<HashSet<_>>()) } fn parse_bookmark_tokens(output: &str) -> Vec<String> { output .split_whitespace() .filter(|token| !token.contains('@')) .map(|token| token.trim().to_string()) .filter(|token| !token.is_empty()) .collect() } fn parse_bookmark_list_names(output: &str) -> Vec<String> { output .lines() .filter_map(|line| { let trimmed = line.trim(); if trimmed.is_empty() { return None; } let name = trimmed .split_once(':') .map(|(name, _)| name.trim()) .unwrap_or(trimmed); Some(name.to_string()) }) .filter(|name| !name.is_empty()) .collect() } fn jj_workspace_names(workspace_root: &Path) -> Result<HashSet<String>> { let output = jj_capture_in( workspace_root, &["workspace", "list", "-T", "name ++ \"\\n\""], )?; Ok(parse_workspace_names(&output).into_iter().collect()) } fn parse_workspace_names(output: &str) -> Vec<String> { output .lines() .map(|line| line.trim()) .filter(|line| !line.is_empty()) .map(ToString::to_string) .collect() } fn inferred_workspace_path(repo_root: &Path, name: &str) -> PathBuf { if name == "default" { repo_root.to_path_buf() } else { workspace_default_path(repo_root, name).unwrap_or_else(|_| repo_root.to_path_buf()) } } fn count_unique_commits(workspace_root: &Path, branch: &str, base: &str) -> usize { if branch == base { return 0; } let revset = format!("ancestors({branch}) ~ ancestors({base})"); jj_capture_in( workspace_root, &[ "log", "-r", &revset, "--no-graph", "-T", "commit_id ++ \"\\n\"", ], ) .map(|output| { output .lines() .filter(|line| !line.trim().is_empty()) .count() }) .unwrap_or(0) } fn resolve_rebase_target(repo_root: &Path, dest: &str, remote: &str) -> String { if jj_bookmark_exists(repo_root, dest) { dest.to_string() } else { format!("{}@{}", dest, remote) } } fn jj_bookmark_exists(repo_root: &Path, name: &str) -> bool { jj_bookmark_names(repo_root) .map(|bookmarks| bookmarks.contains(name)) .unwrap_or(false) } fn default_branch(repo_root: &Path) -> String { if let Some(cfg) = load_jj_config(repo_root) { if let Some(branch) = cfg.default_branch { return branch; } } if git_ref_exists(repo_root, "refs/heads/main") || git_ref_exists(repo_root, "refs/remotes/origin/main") { return "main".to_string(); } if git_ref_exists(repo_root, "refs/heads/master") || git_ref_exists(repo_root, "refs/remotes/origin/master") { return "master".to_string(); } "main".to_string() } fn default_remote(repo_root: &Path) -> String { config::preferred_git_remote_for_repo(repo_root) } fn auto_track_enabled(repo_root: &Path) -> bool { load_jj_config(repo_root) .and_then(|cfg| cfg.auto_track) .unwrap_or(false) } fn load_jj_config(repo_root: &Path) -> Option<config::JjConfig> { let local = repo_root.join("flow.toml"); if local.exists() { if let Ok(cfg) = config::load(&local) { if cfg.jj.is_some() { return cfg.jj; } } } let global = config::default_config_path(); if global.exists() { if let Ok(cfg) = config::load(&global) { if cfg.jj.is_some() { return cfg.jj; } } } None } fn review_workspace_name(branch: &str) -> String { let trimmed = branch.trim().trim_matches('/'); let mut out = String::new(); let mut previous_was_dash = false; for ch in trimmed.chars() { let normalized = if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' { previous_was_dash = false; ch } else { if previous_was_dash { continue; } previous_was_dash = true; '-' }; out.push(normalized); } out.trim_matches('-').to_string() } fn existing_workspace_path( workspace_root: &Path, repo_root: &Path, name: &str, ) -> Result<Option<PathBuf>> { if !jj_workspace_names(workspace_root)?.contains(name) { return Ok(None); } Ok(Some(inferred_workspace_path(repo_root, name))) } struct ReviewWorkspaceBase { rev: String, source: String, } fn resolve_review_workspace_base( repo_root: &Path, branch: &str, remote: &str, explicit_base: Option<&str>, ) -> ReviewWorkspaceBase { if let Some(base) = explicit_base.map(str::trim).filter(|base| !base.is_empty()) { return ReviewWorkspaceBase { rev: base.to_string(), source: format!("explicit base {}", base), }; } if let Some(commit) = git_ref_commit(repo_root, &format!("refs/heads/{branch}")) { return ReviewWorkspaceBase { rev: commit.clone(), source: format!("local branch {branch} ({})", short_commit(&commit)), }; } let remote_ref = format!("refs/remotes/{remote}/{branch}"); if let Some(commit) = git_ref_commit(repo_root, &remote_ref) { return ReviewWorkspaceBase { rev: commit.clone(), source: format!( "remote branch {remote}/{branch} ({})", short_commit(&commit) ), }; } let dest = default_branch(repo_root); let fallback = resolve_rebase_target(repo_root, &dest, remote); ReviewWorkspaceBase { rev: fallback.clone(), source: format!("fallback trunk {}", fallback), } } fn git_ref_commit(repo_root: &Path, reference: &str) -> Option<String> { let output = Command::new("git") .current_dir(repo_root) .args(["rev-parse", reference]) .output() .ok()?; if !output.status.success() { return None; } let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); if sha.is_empty() { None } else { Some(sha) } } fn short_commit(commit: &str) -> &str { const SHORT_COMMIT_LEN: usize = 12; let end = commit .char_indices() .nth(SHORT_COMMIT_LEN) .map(|(idx, _)| idx) .unwrap_or(commit.len()); &commit[..end] } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn default_remote_uses_git_remote_when_set() { let dir = tempdir().expect("tempdir"); let repo_root = dir.path(); std::fs::write( repo_root.join("flow.toml"), "[git]\nremote = \"myflow-i\"\n", ) .expect("write flow.toml"); assert_eq!(default_remote(repo_root), "myflow-i"); } #[test] fn workspace_add_args_use_modern_jj_shape() { let args = workspace_add_args("/tmp/ws-fix-otp", "fix-otp", None); assert_eq!( args, vec!["workspace", "add", "/tmp/ws-fix-otp", "--name", "fix-otp",] ); } #[test] fn workspace_add_args_include_revision_when_set() { let args = workspace_add_args("/tmp/ws-testflight", "testflight", Some("main@upstream")); assert_eq!( args, vec![ "workspace", "add", "/tmp/ws-testflight", "--name", "testflight", "--revision", "main@upstream", ] ); } #[test] fn review_workspace_name_sanitizes_review_branch() { assert_eq!( review_workspace_name("review/nikiv-designer-reactron-rs-rust-first"), "review-nikiv-designer-reactron-rs-rust-first" ); assert_eq!( review_workspace_name("/review//messy branch/"), "review-messy-branch" ); } #[test] fn resolve_review_workspace_base_prefers_explicit_base() { let dir = tempdir().expect("tempdir"); let repo_root = dir.path(); let resolved = resolve_review_workspace_base(repo_root, "review/demo", "origin", Some("main@origin")); assert_eq!(resolved.rev, "main@origin"); assert_eq!(resolved.source, "explicit base main@origin"); } #[test] fn resolve_review_workspace_base_uses_local_branch_commit() { let dir = tempdir().expect("tempdir"); let repo_root = dir.path(); init_git_repo(repo_root); git(repo_root, &["checkout", "-q", "-b", "review/demo"]); let expected = git_capture(repo_root, &["rev-parse", "refs/heads/review/demo"]); let resolved = resolve_review_workspace_base(repo_root, "review/demo", "origin", None); assert_eq!(resolved.rev, expected); assert!(resolved.source.starts_with("local branch review/demo (")); } #[test] fn parse_workspace_names_skips_blank_lines() { let parsed = parse_workspace_names("\ndefault\nreview-demo\n\n"); assert_eq!(parsed, vec!["default", "review-demo"]); } #[test] fn parse_bookmark_list_names_supports_structured_template_output() { let parsed = parse_bookmark_list_names("main\nmain@origin\nreview/demo\n"); assert_eq!(parsed, vec!["main", "main@origin", "review/demo"]); } #[test] fn repo_root_from_git_root_handles_colocated_git_dir() { let repo_root = repo_root_from_git_root("/tmp/prom/.git").expect("repo root"); assert_eq!(repo_root, "/tmp/prom"); } #[test] fn infer_current_ref_prefers_workspace_named_leaf_branch() { let current = infer_current_ref( "review-nikiv-feature", "nikiv", "nikiv", "nikiv-main-intake", "main", &[], &[String::from("nikiv"), String::from("review/nikiv-feature")], ); assert_eq!(current, "review/nikiv-feature"); } fn init_git_repo(repo_root: &Path) { git(repo_root, &["init", "-q"]); git(repo_root, &["config", "user.name", "Flow Tests"]); git( repo_root, &["config", "user.email", "flow-tests@example.com"], ); std::fs::write(repo_root.join("README.md"), "init\n").expect("write README"); git(repo_root, &["add", "README.md"]); git(repo_root, &["commit", "-q", "-m", "init"]); } fn git(repo_root: &Path, args: &[&str]) { let status = Command::new("git") .current_dir(repo_root) .args(args) .status() .expect("run git"); assert!(status.success(), "git {:?} failed", args); } fn git_capture(repo_root: &Path, args: &[&str]) -> String { let output = Command::new("git") .current_dir(repo_root) .args(args) .output() .expect("run git"); assert!(output.status.success(), "git {:?} failed", args); String::from_utf8_lossy(&output.stdout).trim().to_string() } } fn is_jj_repo(path: &Path) -> bool { Command::new("jj") .current_dir(path) .arg("root") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } fn jj_run_in(repo_root: &Path, args: &[&str]) -> Result<()> { let output = Command::new("jj") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() { print!("{}", stdout); } let stderr = String::from_utf8_lossy(&output.stderr); for line in stderr.lines() { if line.contains("Refused to snapshot") { continue; } eprintln!("{}", line); } if !output.status.success() { bail!("jj {} failed", args.join(" ")); } Ok(()) } fn jj_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("jj") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; if !output.status.success() { bail!("jj {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn jj_run_owned_in(repo_root: &Path, args: &[String]) -> Result<()> { let refs: Vec<&str> = args.iter().map(String::as_str).collect(); jj_run_in(repo_root, &refs) } fn git_ref_exists(repo_root: &Path, name: &str) -> bool { Command::new("git") .current_dir(repo_root) .args(["show-ref", "--verify", name]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } fn ensure_git_not_busy(repo_root: &Path) -> Result<()> { let git_dir = git_dir(repo_root)?; let rebase = git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists(); let merge = git_dir.join("MERGE_HEAD").exists(); let cherry_pick = git_dir.join("CHERRY_PICK_HEAD").exists(); let revert = git_dir.join("REVERT_HEAD").exists(); let bisect = git_dir.join("BISECT_LOG").exists(); let unmerged = git_unmerged_files(repo_root); if rebase || merge || cherry_pick || revert || bisect || !unmerged.is_empty() { bail!("Git operation in progress. Run `f git-repair` first."); } Ok(()) } fn git_unmerged_files(repo_root: &Path) -> Vec<String> { let output = Command::new("git") .current_dir(repo_root) .args(["diff", "--name-only", "--diff-filter=U"]) .output(); match output { Ok(out) => String::from_utf8_lossy(&out.stdout) .lines() .filter(|l| !l.trim().is_empty()) .map(|l| l.trim().to_string()) .collect(), Err(_) => Vec::new(), } } fn git_dir(repo_root: &Path) -> Result<PathBuf> { let output = Command::new("git") .current_dir(repo_root) .args(["rev-parse", "--git-dir"]) .output() .context("failed to locate git directory")?; if !output.status.success() { bail!("Not a git repository"); } let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); let dir = PathBuf::from(raw); if dir.is_absolute() { Ok(dir) } else { Ok(repo_root.join(dir)) } } fn workspace_default_path(repo_root: &Path, name: &str) -> Result<PathBuf> { let home = std::env::var("HOME").context("HOME not set")?; let repo_name = repo_root .file_name() .and_then(|n| n.to_str()) .unwrap_or("repo"); Ok(PathBuf::from(home) .join(".jj") .join("workspaces") .join(repo_name) .join(name)) } ================================================ FILE: src/json_parse.rs ================================================ use anyhow::{Result, anyhow}; use serde::de::DeserializeOwned; #[inline] pub fn parse_json_line<T: DeserializeOwned>(line: &str) -> Result<T> { #[cfg(all( feature = "linux-host-simd-json", target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") ))] { let mut buf = line.as_bytes().to_vec(); return simd_json::serde::from_slice(&mut buf) .map_err(|err| anyhow!("failed to decode json line with simd-json: {err}")); } #[cfg(not(all( feature = "linux-host-simd-json", target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") )))] { serde_json::from_str(line).map_err(|err| anyhow!("failed to decode json line: {err}")) } } #[inline] pub fn parse_json_bytes_in_place<T: DeserializeOwned>(bytes: &mut [u8]) -> Result<T> { #[cfg(all( feature = "linux-host-simd-json", target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") ))] { return simd_json::serde::from_slice(bytes) .map_err(|err| anyhow!("failed to decode json bytes with simd-json: {err}")); } #[cfg(not(all( feature = "linux-host-simd-json", target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") )))] { serde_json::from_slice(bytes).map_err(|err| anyhow!("failed to decode json bytes: {err}")) } } ================================================ FILE: src/latest.rs ================================================ use std::path::PathBuf; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use crate::cli::DeployCommand; use crate::deploy; pub fn run() -> Result<()> { let flow_root = flow_repo_root()?; update_flow_repo(&flow_root)?; rebuild_flow(&flow_root)?; reload_fish_shell()?; Ok(()) } fn flow_repo_root() -> Result<PathBuf> { let root = dirs::home_dir() .context("failed to resolve home directory")? .join("code/flow"); if !root.exists() { bail!("flow repo not found at {}", root.display()); } Ok(root) } fn update_flow_repo(root: &PathBuf) -> Result<()> { println!("Updating {}", root.display()); let status = Command::new("git") .args([ "-C", root.to_str().unwrap_or(""), "pull", "--rebase", "--autostash", ]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run git pull")?; if !status.success() { bail!("git pull failed"); } Ok(()) } fn rebuild_flow(root: &PathBuf) -> Result<()> { let prev = std::env::current_dir().context("failed to read current directory")?; std::env::set_current_dir(root) .with_context(|| format!("failed to switch to {}", root.display()))?; let result = deploy::run(DeployCommand { action: None }); std::env::set_current_dir(prev).context("failed to restore previous directory")?; result } fn reload_fish_shell() -> Result<()> { if std::env::var("FISH_VERSION").is_err() { return Ok(()); } if !atty::is(atty::Stream::Stdout) { return Ok(()); } println!("Reloading fish shell..."); #[cfg(unix)] { use std::os::unix::process::CommandExt; let err = Command::new("fish").arg("-l").exec(); bail!("failed to exec fish: {}", err); } #[cfg(not(unix))] { let _ = Command::new("fish").arg("-l").status(); Ok(()) } } ================================================ FILE: src/lib.rs ================================================ pub mod activity_log; pub mod agent_setup; pub mod agents; pub mod ai; pub mod ai_context; pub mod ai_everruns; pub mod ai_server; pub mod ai_taskd; pub mod ai_tasks; pub mod ai_test; pub mod analytics; pub mod archive; pub mod ask; pub mod auth; pub mod base_tool; pub mod branches; pub mod changes; pub mod cli; pub mod code; pub mod codex_memory; pub mod codex_runtime; pub mod codex_skill_eval; pub mod codex_telemetry; pub mod codex_text; pub mod codexd; pub mod commit; pub mod commits; pub mod config; pub mod daemon; pub mod daemon_snapshot; pub mod db; pub mod deploy; pub mod deploy_setup; pub mod deps; pub mod discover; pub mod docs; pub mod doctor; pub mod domains; pub mod env; pub mod env_setup; pub mod explain_commits; pub mod ext; pub mod features; pub mod fish_install; pub mod fish_trace; pub mod fix; pub mod fixup; pub mod flox; pub mod gh_release; pub mod git_guard; pub mod gitignore_policy; pub mod hash; pub mod health; pub mod help_search; pub mod history; pub mod hive; pub mod home; pub mod http_client; pub mod hub; pub mod info; pub mod init; pub mod install; pub mod invariants; #[path = "jazz_state_stub.rs"] pub mod jazz_state; pub mod jj; pub mod json_parse; pub mod latest; pub mod lifecycle; pub mod lmstudio; pub mod log_server; pub mod log_store; pub mod macos; pub mod notify; pub mod opentui_prompt; pub mod otp; pub mod palette; pub mod parallel; #[cfg(test)] mod path_hygiene; pub mod pr_edit; pub mod processes; pub mod project_snapshot; pub mod projects; pub mod proxy; pub mod publish; pub mod push; pub mod recipe; pub mod registry; pub mod release; pub mod release_signing; pub mod repo_capsule; pub mod repos; pub mod reviews_todo; pub mod rl_signals; pub mod running; pub mod sealer_crypto; pub mod secret_redact; pub mod seq_client; pub mod seq_rpc; pub mod services; pub mod setup; pub mod skills; pub mod ssh; pub mod ssh_keys; pub mod start; pub mod storage; pub mod supervisor; pub mod sync; pub mod task_failure_agents; pub mod task_match; pub mod tasks; pub mod todo; pub mod tools; #[path = "traces_stub.rs"] pub mod traces; pub mod undo; pub mod upgrade; pub mod upstream; pub mod url_inspect; pub mod usage; pub mod vcs; pub mod watchers; pub mod web; pub mod workflow; /// Initialize tracing with a default filter if `RUST_LOG` is unset. pub fn init_tracing() { let default_filter = "flowd=info,axum=warn,tower=warn"; let filter_layer = std::env::var("RUST_LOG").unwrap_or_else(|_| default_filter.to_string()); tracing_subscriber::fmt() .with_env_filter(filter_layer) .with_target(false) .compact() .init(); } ================================================ FILE: src/lifecycle.rs ================================================ use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use anyhow::{Context, Result, anyhow, bail}; use crate::{ cli::{ DomainsAction, DomainsAddOpts, DomainsCommand, DomainsEngineArg, DomainsRmOpts, KillOpts, LifecycleRunOpts, TaskRunOpts, }, config::{self, Config, LifecycleDomainsConfig}, domains, processes, tasks, }; static RUNTIME_PREFERRED_URL: OnceLock<Mutex<Option<String>>> = OnceLock::new(); pub fn run_up(opts: LifecycleRunOpts) -> Result<()> { let project = resolve_project_config(&opts.config)?; let lifecycle = project.config.lifecycle.clone().unwrap_or_default(); let preferred_url = if let Some(domains_cfg) = lifecycle.domains.as_ref() { match ensure_domains_up(domains_cfg) { Ok(()) => lifecycle_preferred_url(domains_cfg), Err(err) => { eprintln!( "WARN lifecycle domains unavailable; continuing without localhost routing" ); eprintln!("WARN {}", err); None } } } else { None }; let _preferred_url_guard = ScopedPreferredUrl::set(preferred_url); let ran_task = match lifecycle.up_task.as_deref() { Some(task) => run_required_task(&project.flow_path, task, opts.args)?, None => run_optional_task_chain(&project.flow_path, &["up", "dev"], opts.args)?, }; if !ran_task { bail!( "No lifecycle up task found. Define task 'up' or 'dev', or set [lifecycle].up_task in {}", project.flow_path.display() ); } Ok(()) } pub fn run_down(opts: LifecycleRunOpts) -> Result<()> { let project = resolve_project_config(&opts.config)?; let lifecycle = project.config.lifecycle.clone().unwrap_or_default(); let mut task_ran = match lifecycle.down_task.as_deref() { Some(task) => run_required_task(&project.flow_path, task, opts.args.clone())?, None => run_optional_task_chain(&project.flow_path, &["down"], opts.args.clone())?, }; if !task_ran && lifecycle.down_task.is_none() { processes::kill_processes(KillOpts { config: project.flow_path.clone(), task: None, pid: None, all: true, force: false, timeout: 5, })?; task_ran = true; } let mut domain_action_ran = false; if let Some(domains_cfg) = lifecycle.domains.as_ref() { domain_action_ran = run_domains_down(domains_cfg)?; } if !task_ran && !domain_action_ran { bail!( "No lifecycle down action found. Define task 'down', set [lifecycle].down_task, or enable [lifecycle.domains] cleanup in {}", project.flow_path.display() ); } Ok(()) } fn run_required_task(config_path: &Path, task_name: &str, args: Vec<String>) -> Result<bool> { match run_task(config_path, task_name, args) { Ok(()) => Ok(true), Err(err) if is_task_not_found(&err) => { bail!("lifecycle task '{}' not found", task_name); } Err(err) => Err(err), } } fn run_optional_task_chain( config_path: &Path, candidates: &[&str], args: Vec<String>, ) -> Result<bool> { for name in candidates { match run_task(config_path, name, args.clone()) { Ok(()) => return Ok(true), Err(err) if is_task_not_found(&err) => continue, Err(err) => return Err(err), } } Ok(false) } fn run_task(config_path: &Path, task_name: &str, args: Vec<String>) -> Result<()> { tasks::run(TaskRunOpts { config: config_path.to_path_buf(), delegate_to_hub: false, hub_host: IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: task_name.to_string(), args, }) } fn ensure_domains_up(cfg: &LifecycleDomainsConfig) -> Result<()> { let host = lifecycle_domain_host(cfg)?; let target = lifecycle_domain_target(cfg)?; let engine = parse_domains_engine(cfg.engine.as_deref())?; add_lifecycle_route(engine, host, target)?; for alias in &cfg.aliases { let alias_host = alias .host .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .ok_or_else(|| anyhow!("lifecycle.domains.aliases[].host is required"))?; let alias_target = alias .target .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .ok_or_else(|| anyhow!("lifecycle.domains.aliases[].target is required"))?; add_lifecycle_route(engine, alias_host, alias_target)?; } domains::run(DomainsCommand { engine, action: Some(DomainsAction::Up), })?; println!("Lifecycle domains ready: http://{}", host); Ok(()) } fn lifecycle_preferred_url(cfg: &LifecycleDomainsConfig) -> Option<String> { let host = lifecycle_domain_host(cfg).ok()?; Some(format!("http://{}", host)) } fn run_domains_down(cfg: &LifecycleDomainsConfig) -> Result<bool> { let mut changed = false; let engine = parse_domains_engine(cfg.engine.as_deref())?; if cfg.remove_on_down.unwrap_or(false) { let host = lifecycle_domain_host(cfg) .map_err(|_| anyhow!("lifecycle.domains.host is required when remove_on_down=true"))?; remove_lifecycle_route(engine, host)?; for alias in &cfg.aliases { let alias_host = alias .host .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .ok_or_else(|| { anyhow!("lifecycle.domains.aliases[].host is required when remove_on_down=true") })?; remove_lifecycle_route(engine, alias_host)?; } changed = true; } if cfg.stop_proxy_on_down.unwrap_or(false) { domains::run(DomainsCommand { engine, action: Some(DomainsAction::Down), })?; changed = true; } Ok(changed) } fn lifecycle_domain_host(cfg: &LifecycleDomainsConfig) -> Result<&str> { cfg.host .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .ok_or_else(|| anyhow!("lifecycle.domains.host is required")) } fn lifecycle_domain_target(cfg: &LifecycleDomainsConfig) -> Result<&str> { cfg.target .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .ok_or_else(|| anyhow!("lifecycle.domains.target is required")) } fn add_lifecycle_route(engine: Option<DomainsEngineArg>, host: &str, target: &str) -> Result<()> { domains::run(DomainsCommand { engine, action: Some(DomainsAction::Add(DomainsAddOpts { host: host.to_string(), target: target.to_string(), replace: true, })), }) } fn remove_lifecycle_route(engine: Option<DomainsEngineArg>, host: &str) -> Result<()> { domains::run(DomainsCommand { engine, action: Some(DomainsAction::Rm(DomainsRmOpts { host: host.to_string(), })), }) } fn parse_domains_engine(raw: Option<&str>) -> Result<Option<DomainsEngineArg>> { let Some(raw) = raw else { return Ok(None); }; let engine = match raw.trim().to_ascii_lowercase().as_str() { "docker" => DomainsEngineArg::Docker, "native" => DomainsEngineArg::Native, other => bail!( "invalid lifecycle.domains.engine '{}': expected 'docker' or 'native'", other ), }; Ok(Some(engine)) } fn resolve_project_config(config_arg: &Path) -> Result<ProjectConfig> { let cwd = std::env::current_dir().context("Failed to read current directory")?; let flow_path = resolve_flow_path(config_arg, &cwd)?; let cfg = config::load(&flow_path) .with_context(|| format!("Failed to load {}", flow_path.display()))?; Ok(ProjectConfig { flow_path, config: cfg, }) } fn resolve_flow_path(config_arg: &Path, cwd: &Path) -> Result<PathBuf> { if config_arg.is_absolute() { if config_arg.exists() { return Ok(config_arg.to_path_buf()); } bail!("config path not found: {}", config_arg.display()); } let direct = cwd.join(config_arg); if direct.exists() { return Ok(direct); } if config_arg == Path::new("flow.toml") { if let Some(found) = find_flow_toml_upwards(cwd) { return Ok(found); } } bail!("config path not found: {}", direct.display()); } fn find_flow_toml_upwards(start: &Path) -> Option<PathBuf> { let mut cur = start.to_path_buf(); loop { let cand = cur.join("flow.toml"); if cand.exists() { return Some(cand); } if !cur.pop() { break; } } None } fn is_task_not_found(err: &anyhow::Error) -> bool { let msg = err.to_string().to_ascii_lowercase(); msg.contains("task '") && msg.contains("not found") } pub(crate) fn runtime_preferred_url() -> Option<String> { preferred_url_slot() .lock() .unwrap_or_else(|e| e.into_inner()) .clone() } fn preferred_url_slot() -> &'static Mutex<Option<String>> { RUNTIME_PREFERRED_URL.get_or_init(|| Mutex::new(None)) } struct ScopedPreferredUrl { prev: Option<String>, } impl ScopedPreferredUrl { fn set(value: Option<String>) -> Self { let mut guard = preferred_url_slot() .lock() .unwrap_or_else(|e| e.into_inner()); let prev = guard.clone(); *guard = value; Self { prev } } } impl Drop for ScopedPreferredUrl { fn drop(&mut self) { let mut guard = preferred_url_slot() .lock() .unwrap_or_else(|e| e.into_inner()); *guard = self.prev.clone(); } } struct ProjectConfig { flow_path: PathBuf, config: Config, } ================================================ FILE: src/lmstudio.rs ================================================ //! Simple LM Studio API client for task matching. use anyhow::{Context, Result}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; const DEFAULT_PORT: u16 = 1234; const DEFAULT_MODEL: &str = "qwen3-8b"; #[derive(Debug, Serialize)] struct ChatRequest { model: String, messages: Vec<ChatMessage>, temperature: f32, } #[derive(Debug, Serialize)] struct ChatMessage { role: String, content: String, } #[derive(Debug, Deserialize)] struct ChatResponse { choices: Vec<Choice>, } #[derive(Debug, Deserialize)] struct Choice { message: Option<ResponseMessage>, } #[derive(Debug, Deserialize)] struct ResponseMessage { content: String, } /// Send a prompt to LM Studio and get a response. pub fn quick_prompt(prompt: &str, model: Option<&str>, port: Option<u16>) -> Result<String> { let prompt = prompt.trim(); let model = model.unwrap_or(DEFAULT_MODEL); let port = port.unwrap_or(DEFAULT_PORT); let client = Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .context("failed to create HTTP client")?; let url = format!("http://localhost:{port}/v1/chat/completions"); let body = ChatRequest { model: model.to_string(), messages: vec![ChatMessage { role: "user".to_string(), content: prompt.to_string(), }], temperature: 0.1, // Low temperature for deterministic task matching }; let resp = client .post(&url) .json(&body) .send() .with_context(|| format!("failed to connect to LM Studio at localhost:{port}"))?; if !resp.status().is_success() { anyhow::bail!( "LM Studio returned status {}: {}", resp.status(), resp.text().unwrap_or_default() ); } let text_body = resp.text().context("failed to read LM Studio response")?; let parsed: ChatResponse = serde_json::from_str(&text_body).context("failed to parse LM Studio response")?; let text = parsed .choices .first() .and_then(|c| c.message.as_ref()) .map(|m| m.content.trim().to_string()) .unwrap_or_default(); Ok(text) } /// Check if LM Studio is running and accessible. #[allow(dead_code)] pub fn is_available(port: Option<u16>) -> bool { let port = port.unwrap_or(DEFAULT_PORT); let client = match Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() { Ok(c) => c, Err(_) => return false, }; let url = format!("http://localhost:{port}/v1/models"); client .get(&url) .send() .map(|r| r.status().is_success()) .unwrap_or(false) } ================================================ FILE: src/log_server.rs ================================================ use std::fs; use std::net::SocketAddr; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; use std::time::Duration; use anyhow::{Context, Result, bail}; use axum::{ Router, extract::{Json as AxumJson, Path as AxumPath, Query, State}, http::{Method, StatusCode}, response::{ IntoResponse, Json, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; use futures::stream::{self, Stream, StreamExt}; use reqwest::blocking::Client; use serde::Deserialize; use serde_json::json; use tower_http::cors::{Any, CorsLayer}; use crate::cli::{ServerAction, ServerOpts}; use crate::log_store::{self, LogEntry, LogQuery}; use crate::pr_edit::PrEditService; use crate::{ai, config, daemon_snapshot, explain_commits, projects, skills, workflow}; #[derive(Clone)] struct AppState { pr_edit: Arc<tokio::sync::RwLock<Option<Arc<PrEditService>>>>, pr_edit_error: Arc<tokio::sync::RwLock<Option<String>>>, } /// Run the flow HTTP server for log ingestion. pub fn run(opts: ServerOpts) -> Result<()> { let host = opts.host.clone(); let port = opts.port; match opts.action { Some(ServerAction::Stop) => stop_server(), Some(ServerAction::Foreground) => run_foreground(&host, port), None => ensure_server(&host, port), } } /// Ensure server is running in background, start if not fn ensure_server(host: &str, port: u16) -> Result<()> { if server_healthy(host, port) { println!("Flow server already running at http://{}:{}", host, port); return Ok(()); } // Kill stale process if exists if let Some(pid) = load_server_pid()? { if process_alive(pid) { terminate_process(pid).ok(); } remove_server_pid().ok(); } // Start in background let exe = std::env::current_exe().context("failed to get current exe")?; let mut cmd = Command::new(exe); cmd.arg("server") .arg("--host") .arg(host) .arg("--port") .arg(port.to_string()) .arg("foreground") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); let child = cmd.spawn().context("failed to start server process")?; persist_server_pid(child.id())?; // Wait for health for _ in 0..20 { std::thread::sleep(Duration::from_millis(100)); if server_healthy(host, port) { println!("Flow server started at http://{}:{}", host, port); return Ok(()); } } println!( "Flow server starting at http://{}:{} (may take a moment)", host, port ); Ok(()) } /// Run server in foreground (used by background process) fn run_foreground(host: &str, port: u16) -> Result<()> { // Initialize database and schema on startup let conn = log_store::open_log_db().context("failed to initialize log database")?; drop(conn); let addr: SocketAddr = format!("{}:{}", host, port) .parse() .context("invalid host:port")?; let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; rt.block_on(async { let cors = CorsLayer::new() .allow_origin(Any) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_headers(Any); // Start PR edit watcher in the background so it can never block the server startup. let pr_edit = Arc::new(tokio::sync::RwLock::new(None)); let pr_edit_error = Arc::new(tokio::sync::RwLock::new(None)); { let pr_edit = Arc::clone(&pr_edit); let pr_edit_error = Arc::clone(&pr_edit_error); tokio::spawn(async move { match PrEditService::start().await { Ok(svc) => { *pr_edit.write().await = Some(svc); *pr_edit_error.write().await = None; tracing::info!("pr-edit watcher started"); } Err(err) => { *pr_edit_error.write().await = Some(format!("{err:#}")); tracing::warn!(?err, "failed to start pr-edit watcher"); } } }); } let state = AppState { pr_edit, pr_edit_error, }; let router = Router::new() .route("/health", get(health)) .route("/codex/skills", get(codex_skills)) .route("/codex/eval", get(codex_eval)) .route("/codex/resolve", post(codex_resolve)) .route("/codex/skills/sync", post(codex_skills_sync)) .route("/codex/skills/reload", post(codex_skills_reload)) .route("/daemons", get(daemons)) .route("/daemons/{name}/start", post(daemon_start)) .route("/daemons/{name}/stop", post(daemon_stop)) .route("/daemons/{name}/restart", post(daemon_restart)) .route("/logs/ingest", post(logs_ingest)) .route("/logs/query", get(logs_query)) .route("/logs/errors/stream", get(logs_errors_stream)) .route("/pr-edit/status", get(pr_edit_status)) .route("/pr-edit/rescan", post(pr_edit_rescan)) // Flow projects + AI sessions .route("/projects", get(projects_list_all)) .route("/projects/{name}/sessions", get(project_sessions)) .route("/sessions/{id}", get(session_detail)) .route("/workflow/overview", get(workflow_overview)) .route( "/projects/{name}/commit-explanations", get(project_commit_explanations), ) .route( "/projects/{name}/commit-explanations/{sha}", get(project_commit_explanation_detail), ) .layer(cors) .with_state(state); let listener = tokio::net::TcpListener::bind(addr) .await .context("failed to bind server")?; axum::serve(listener, router) .await .context("server error")?; Ok(()) }) } fn stop_server() -> Result<()> { if let Some(pid) = load_server_pid()? { terminate_process(pid).ok(); remove_server_pid().ok(); println!("Flow server stopped"); } else { println!("Flow server not running"); } Ok(()) } fn server_pid_path() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".config/flow/server.pid") } fn load_server_pid() -> Result<Option<u32>> { let path = server_pid_path(); if !path.exists() { return Ok(None); } let contents = fs::read_to_string(&path)?; let pid: u32 = contents.trim().parse().unwrap_or(0); Ok(if pid == 0 { None } else { Some(pid) }) } fn persist_server_pid(pid: u32) -> Result<()> { let path = server_pid_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(&path, pid.to_string())?; Ok(()) } fn remove_server_pid() -> Result<()> { let path = server_pid_path(); if path.exists() { fs::remove_file(path).ok(); } Ok(()) } fn server_healthy(host: &str, port: u16) -> bool { let url = format!("http://{}:{}/health", host, port); Client::builder() .timeout(Duration::from_millis(500)) .build() .ok() .and_then(|c| c.get(&url).send().ok()) .map(|r| r.status().is_success()) .unwrap_or(false) } fn process_alive(pid: u32) -> bool { Command::new("kill") .arg("-0") .arg(pid.to_string()) .status() .map(|s| s.success()) .unwrap_or(false) } fn terminate_process(pid: u32) -> Result<()> { let status = Command::new("kill") .arg(pid.to_string()) .status() .context("failed to kill process")?; if status.success() { Ok(()) } else { bail!("kill failed") } } async fn health() -> impl IntoResponse { Json(json!({ "status": "ok" })) } #[derive(Debug, Deserialize)] struct CodexSkillsQuery { path: Option<String>, limit: Option<usize>, } #[derive(Debug, Deserialize)] struct CodexEvalQuery { path: Option<String>, limit: Option<usize>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodexSkillsSyncRequest { path: Option<String>, #[serde(default)] skills: Vec<String>, #[serde(default)] force: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodexSkillsReloadRequest { path: Option<String>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodexResolveRequest { path: Option<String>, query: String, #[serde(default)] exact_cwd: bool, } fn resolve_codex_skills_target(path: Option<&str>) -> PathBuf { let candidate = path .map(config::expand_path) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| config::expand_path("~"))); if candidate.is_absolute() { candidate } else { std::env::current_dir() .unwrap_or_else(|_| config::expand_path("~")) .join(candidate) } } async fn codex_skills(Query(query): Query<CodexSkillsQuery>) -> impl IntoResponse { let target_path = resolve_codex_skills_target(query.path.as_deref()); let limit = query.limit.unwrap_or(12).clamp(1, 50); let result = tokio::task::spawn_blocking(move || { ai::codex_skills_dashboard_snapshot(&target_path, limit) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex skills task failed: {err}") })), ) .into_response(), } } async fn codex_eval(Query(query): Query<CodexEvalQuery>) -> impl IntoResponse { let target_path = resolve_codex_skills_target(query.path.as_deref()); let limit = query.limit.unwrap_or(200).clamp(20, 1000); let result = tokio::task::spawn_blocking(move || ai::codex_eval_snapshot(&target_path, limit)) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex eval task failed: {err}") })), ) .into_response(), } } async fn codex_resolve(AxumJson(payload): AxumJson<CodexResolveRequest>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { ai::codex_resolve_inspector(payload.path, payload.query, payload.exact_cwd) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex resolve task failed: {err}") })), ) .into_response(), } } async fn codex_skills_sync(AxumJson(payload): AxumJson<CodexSkillsSyncRequest>) -> impl IntoResponse { let target_path = resolve_codex_skills_target(payload.path.as_deref()); let result = tokio::task::spawn_blocking(move || { let installed = ai::codex_skill_source_sync(&target_path, &payload.skills, payload.force)?; Ok::<_, anyhow::Error>(json!({ "targetPath": target_path.display().to_string(), "installed": installed, })) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(snapshot)).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex skills sync task failed: {err}") })), ) .into_response(), } } async fn codex_skills_reload( AxumJson(payload): AxumJson<CodexSkillsReloadRequest>, ) -> impl IntoResponse { let target_path = resolve_codex_skills_target(payload.path.as_deref()); let result = tokio::task::spawn_blocking(move || { let reloaded = skills::reload_codex_skills_for_cwd(&target_path)?; Ok::<_, anyhow::Error>(json!({ "targetPath": target_path.display().to_string(), "reloaded": reloaded, })) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(snapshot)).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex skills reload task failed: {err}") })), ) .into_response(), } } async fn daemons() -> impl IntoResponse { let result = tokio::task::spawn_blocking(|| daemon_snapshot::load_daemon_snapshot(None)).await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("daemon snapshot task failed: {err}") })), ) .into_response(), } } async fn daemon_start(AxumPath(name): AxumPath<String>) -> impl IntoResponse { daemon_action_response(name, daemon_snapshot::FlowDaemonAction::Start).await } async fn daemon_stop(AxumPath(name): AxumPath<String>) -> impl IntoResponse { daemon_action_response(name, daemon_snapshot::FlowDaemonAction::Stop).await } async fn daemon_restart(AxumPath(name): AxumPath<String>) -> impl IntoResponse { daemon_action_response(name, daemon_snapshot::FlowDaemonAction::Restart).await } async fn daemon_action_response( name: String, action: daemon_snapshot::FlowDaemonAction, ) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { daemon_snapshot::run_daemon_action(&name, action, None) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("daemon action task failed: {err}") })), ) .into_response(), } } async fn pr_edit_status(State(state): State<AppState>) -> impl IntoResponse { let guard = state.pr_edit.read().await; match guard.as_ref() { Some(svc) => (StatusCode::OK, Json(svc.status_snapshot().await)).into_response(), None => { let err = state.pr_edit_error.read().await.clone(); ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "pr-edit watcher not running", "detail": err })), ) .into_response() } } } async fn pr_edit_rescan(State(state): State<AppState>) -> impl IntoResponse { let guard = state.pr_edit.read().await; match guard.as_ref() { Some(svc) => match svc.rescan().await { Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))).into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), }, None => { let err = state.pr_edit_error.read().await.clone(); ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": "pr-edit watcher not running", "detail": err })), ) .into_response() } } } #[derive(Debug, Deserialize)] #[serde(untagged)] enum IngestRequest { Single(LogEntry), Batch(Vec<LogEntry>), } async fn logs_ingest(Json(payload): Json<IngestRequest>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let mut conn = match log_store::open_log_db() { Ok(c) => c, Err(e) => return Err(e), }; match payload { IngestRequest::Single(entry) => { let id = log_store::insert_log(&conn, &entry)?; Ok(json!({ "inserted": 1, "ids": [id] })) } IngestRequest::Batch(entries) => { let ids = log_store::insert_logs(&mut conn, &entries)?; Ok(json!({ "inserted": ids.len(), "ids": ids })) } } }) .await; match result { Ok(Ok(response)) => (StatusCode::OK, Json(response)).into_response(), Ok(Err(err)) => { tracing::error!(?err, "log ingest failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response() } Err(err) => { tracing::error!(?err, "log ingest task panicked"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "internal error" })), ) .into_response() } } } async fn logs_query(Query(query): Query<LogQuery>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let conn = log_store::open_log_db()?; log_store::query_logs(&conn, &query) }) .await; match result { Ok(Ok(entries)) => (StatusCode::OK, Json(entries)).into_response(), Ok(Err(err)) => { tracing::error!(?err, "log query failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response() } Err(err) => { tracing::error!(?err, "log query task panicked"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "internal error" })), ) .into_response() } } } // ============================================================================ // Flow Projects + AI Sessions // ============================================================================ /// GET /projects - List all registered Flow projects. async fn projects_list_all() -> impl IntoResponse { let result = tokio::task::spawn_blocking(|| projects::list_projects()).await; match result { Ok(Ok(entries)) => (StatusCode::OK, Json(json!({ "projects": entries }))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), } } /// GET /projects/:name/sessions - List AI sessions for a project. async fn project_sessions(AxumPath(name): AxumPath<String>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let project = projects::resolve_project(&name)?; let project = project.ok_or_else(|| anyhow::anyhow!("project not found: {}", name))?; ai::get_sessions_for_web(&project.project_root) }) .await; match result { Ok(Ok(sessions)) => (StatusCode::OK, Json(json!({ "sessions": sessions }))).into_response(), Ok(Err(err)) => { let status = if err.to_string().contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; (status, Json(json!({ "error": err.to_string() }))).into_response() } Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), } } /// GET /workflow/overview - List repo/workspace/branch/PR workflow state for registered projects. async fn workflow_overview() -> impl IntoResponse { let result = tokio::task::spawn_blocking(workflow::load_workflow_overview).await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), } } #[derive(Debug, Deserialize)] struct SessionDetailQuery { project: Option<String>, } #[derive(Debug, Deserialize)] struct CommitExplanationsQuery { limit: Option<usize>, } /// GET /sessions/:id?project=/path/to/root - Get full session conversation. async fn session_detail( AxumPath(session_id): AxumPath<String>, Query(query): Query<SessionDetailQuery>, ) -> impl IntoResponse { let Some(project) = query .project .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) else { return ( StatusCode::BAD_REQUEST, Json(json!({ "error": "missing ?project= query parameter" })), ) .into_response(); }; let project_root = std::path::PathBuf::from(project); let result = tokio::task::spawn_blocking(move || { ai::get_sessions_for_web(&project_root) .map(|sessions| sessions.into_iter().find(|s| s.id == session_id)) }) .await; match result { Ok(Ok(Some(session))) => (StatusCode::OK, Json(json!(session))).into_response(), Ok(Ok(None)) => ( StatusCode::NOT_FOUND, Json(json!({ "error": "session not found" })), ) .into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), } } /// GET /projects/:name/commit-explanations?limit=50 - List commit explanations for a project. async fn project_commit_explanations( AxumPath(name): AxumPath<String>, Query(query): Query<CommitExplanationsQuery>, ) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let project = projects::resolve_project(&name)?; let project = project.ok_or_else(|| anyhow::anyhow!("project not found: {}", name))?; explain_commits::list_explained_commits(&project.project_root, query.limit) }) .await; match result { Ok(Ok(commits)) => (StatusCode::OK, Json(json!({ "commits": commits }))).into_response(), Ok(Err(err)) => { let status = if err.to_string().contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; (status, Json(json!({ "error": err.to_string() }))).into_response() } Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), } } /// GET /projects/:name/commit-explanations/:sha - Get one commit explanation by SHA/prefix. async fn project_commit_explanation_detail( AxumPath((name, sha)): AxumPath<(String, String)>, ) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let project = projects::resolve_project(&name)?; let project = project.ok_or_else(|| anyhow::anyhow!("project not found: {}", name))?; explain_commits::get_explained_commit(&project.project_root, &sha) }) .await; match result { Ok(Ok(Some(commit))) => (StatusCode::OK, Json(json!(commit))).into_response(), Ok(Ok(None)) => ( StatusCode::NOT_FOUND, Json(json!({ "error": "commit explanation not found" })), ) .into_response(), Ok(Err(err)) => { let status = if err.to_string().contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; (status, Json(json!({ "error": err.to_string() }))).into_response() } Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), } } /// SSE stream of error logs - polls DB and emits new errors async fn logs_errors_stream() -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> { let last_id = Arc::new(AtomicI64::new(0)); // Get current max ID to start from if let Ok(conn) = log_store::open_log_db() { if let Ok(entries) = log_store::query_logs( &conn, &LogQuery { log_type: Some("error".to_string()), limit: 1, ..Default::default() }, ) { if let Some(entry) = entries.first() { last_id.store(entry.id, Ordering::SeqCst); } } } let stream = stream::unfold(last_id, |last_id| async move { tokio::time::sleep(Duration::from_millis(500)).await; let current_last = last_id.load(Ordering::SeqCst); let new_errors = tokio::task::spawn_blocking(move || { let conn = match log_store::open_log_db() { Ok(c) => c, Err(_) => return Vec::new(), }; log_store::query_logs( &conn, &LogQuery { log_type: Some("error".to_string()), limit: 100, ..Default::default() }, ) .unwrap_or_default() .into_iter() .filter(|e| e.id > current_last) .collect::<Vec<_>>() }) .await .unwrap_or_default(); let events: Vec<Result<Event, std::convert::Infallible>> = new_errors .into_iter() .map(|entry| { last_id.store( entry.id.max(last_id.load(Ordering::SeqCst)), Ordering::SeqCst, ); let data = serde_json::to_string(&entry).unwrap_or_default(); Ok(Event::default().data(data)) }) .collect(); Some((stream::iter(events), last_id)) }) .flatten(); Sse::new(stream).keep_alive(KeepAlive::default()) } ================================================ FILE: src/log_store.rs ================================================ use anyhow::{Context, Result}; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; use crate::db; use crate::secret_redact; /// A log entry for ingestion and storage. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogEntry { pub project: String, pub content: String, pub timestamp: i64, // unix ms #[serde(rename = "type")] pub log_type: String, // "log" | "error" pub service: String, // task name or custom service #[serde(skip_serializing_if = "Option::is_none")] pub stack: Option<String>, #[serde(default = "default_format")] pub format: String, // "json" | "text" } fn default_format() -> String { "text".to_string() } /// Stored log entry with ID. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredLogEntry { pub id: i64, #[serde(flatten)] pub entry: LogEntry, } /// Query parameters for filtering logs. #[derive(Debug, Clone, Deserialize)] pub struct LogQuery { pub project: Option<String>, pub service: Option<String>, #[serde(rename = "type")] pub log_type: Option<String>, pub since: Option<i64>, // timestamp ms pub until: Option<i64>, // timestamp ms #[serde(default = "default_limit")] pub limit: usize, #[serde(default)] pub offset: usize, } fn default_limit() -> usize { 100 } impl Default for LogQuery { fn default() -> Self { Self { project: None, service: None, log_type: None, since: None, until: None, limit: default_limit(), offset: 0, } } } /// Initialize the logs table schema. pub fn init_schema(conn: &Connection) -> Result<()> { conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, content TEXT NOT NULL, timestamp INTEGER NOT NULL, log_type TEXT NOT NULL, service TEXT NOT NULL, stack TEXT, format TEXT NOT NULL DEFAULT 'text' ); CREATE INDEX IF NOT EXISTS idx_logs_project ON logs(project); CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp); CREATE INDEX IF NOT EXISTS idx_logs_type ON logs(log_type); CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service); "#, ) .context("failed to create logs schema")?; Ok(()) } /// Insert a single log entry. pub fn insert_log(conn: &Connection, entry: &LogEntry) -> Result<i64> { let sanitized = sanitize_entry(entry); conn.execute( r#" INSERT INTO logs (project, content, timestamp, log_type, service, stack, format) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) "#, params![ sanitized.project, sanitized.content, sanitized.timestamp, sanitized.log_type, sanitized.service, sanitized.stack, sanitized.format, ], ) .context("failed to insert log")?; Ok(conn.last_insert_rowid()) } /// Insert multiple log entries in a transaction. pub fn insert_logs(conn: &mut Connection, entries: &[LogEntry]) -> Result<Vec<i64>> { let tx = conn.transaction()?; let mut ids = Vec::with_capacity(entries.len()); for entry in entries { let sanitized = sanitize_entry(entry); tx.execute( r#" INSERT INTO logs (project, content, timestamp, log_type, service, stack, format) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) "#, params![ sanitized.project, sanitized.content, sanitized.timestamp, sanitized.log_type, sanitized.service, sanitized.stack, sanitized.format, ], ) .context("failed to insert log")?; ids.push(tx.last_insert_rowid()); } tx.commit()?; Ok(ids) } /// Query logs with filters. pub fn query_logs(conn: &Connection, query: &LogQuery) -> Result<Vec<StoredLogEntry>> { let mut sql = String::from( "SELECT id, project, content, timestamp, log_type, service, stack, format FROM logs WHERE 1=1", ); let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); if let Some(ref project) = query.project { sql.push_str(" AND project = ?"); params_vec.push(Box::new(project.clone())); } if let Some(ref service) = query.service { sql.push_str(" AND service = ?"); params_vec.push(Box::new(service.clone())); } if let Some(ref log_type) = query.log_type { sql.push_str(" AND log_type = ?"); params_vec.push(Box::new(log_type.clone())); } if let Some(since) = query.since { sql.push_str(" AND timestamp >= ?"); params_vec.push(Box::new(since)); } if let Some(until) = query.until { sql.push_str(" AND timestamp <= ?"); params_vec.push(Box::new(until)); } sql.push_str(" ORDER BY timestamp DESC LIMIT ? OFFSET ?"); params_vec.push(Box::new(query.limit as i64)); params_vec.push(Box::new(query.offset as i64)); let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(params_refs.as_slice(), |row| { let content: String = row.get(2)?; let stack: Option<String> = row.get(6)?; Ok(StoredLogEntry { id: row.get(0)?, entry: LogEntry { project: row.get(1)?, content: secret_redact::redact_text(&content), timestamp: row.get(3)?, log_type: row.get(4)?, service: row.get(5)?, stack: stack.map(|value| secret_redact::redact_text(&value)), format: row.get(7)?, }, }) })?; let mut entries = Vec::new(); for row in rows { entries.push(row?); } Ok(entries) } /// Get error logs for a project (convenience function). pub fn get_errors(conn: &Connection, project: &str, limit: usize) -> Result<Vec<StoredLogEntry>> { query_logs( conn, &LogQuery { project: Some(project.to_string()), log_type: Some("error".to_string()), limit, ..Default::default() }, ) } /// Open database and ensure schema exists. pub fn open_log_db() -> Result<Connection> { let conn = db::open_db()?; init_schema(&conn)?; Ok(conn) } fn sanitize_entry(entry: &LogEntry) -> LogEntry { LogEntry { project: entry.project.clone(), content: secret_redact::redact_text(&entry.content), timestamp: entry.timestamp, log_type: entry.log_type.clone(), service: entry.service.clone(), stack: entry .stack .as_ref() .map(|value| secret_redact::redact_text(value)), format: entry.format.clone(), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_insert_and_query() { let conn = Connection::open_in_memory().unwrap(); init_schema(&conn).unwrap(); let entry = LogEntry { project: "test-project".to_string(), content: "Test log message".to_string(), timestamp: 1234567890000, log_type: "log".to_string(), service: "web".to_string(), stack: None, format: "text".to_string(), }; let id = insert_log(&conn, &entry).unwrap(); assert!(id > 0); let results = query_logs( &conn, &LogQuery { project: Some("test-project".to_string()), ..Default::default() }, ) .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entry.content, "Test log message"); } #[test] fn test_error_query() { let conn = Connection::open_in_memory().unwrap(); init_schema(&conn).unwrap(); let log_entry = LogEntry { project: "test".to_string(), content: "Normal log".to_string(), timestamp: 1000, log_type: "log".to_string(), service: "api".to_string(), stack: None, format: "text".to_string(), }; let error_entry = LogEntry { project: "test".to_string(), content: "Error occurred".to_string(), timestamp: 2000, log_type: "error".to_string(), service: "api".to_string(), stack: Some("at main.rs:10".to_string()), format: "text".to_string(), }; insert_log(&conn, &log_entry).unwrap(); insert_log(&conn, &error_entry).unwrap(); let errors = get_errors(&conn, "test", 10).unwrap(); assert_eq!(errors.len(), 1); assert_eq!(errors[0].entry.log_type, "error"); } } ================================================ FILE: src/logs.rs ================================================ use std::{ io::{BufRead, BufReader}, thread, time::Duration, }; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use crate::{ cli::LogsOpts, servers::{LogLine, LogStream, ServerSnapshot}, }; pub fn run(opts: LogsOpts) -> Result<()> { if opts.follow && opts.server.is_none() { bail!("--follow requires specifying --server <name>"); } let base_url = format!("http://{}:{}", opts.host, opts.port); let use_color = !opts.no_color; let client = Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() .context("failed to build HTTP client")?; if let Some(server) = opts.server.as_deref() { if opts.follow { stream_server_logs(server, opts.host, opts.port, use_color)?; } else { let logs = fetch_logs(&client, &base_url, server, opts.limit)?; print_logs(&logs, use_color); } return Ok(()); } match fetch_all_logs(&client, &base_url, opts.limit) { Ok(logs) => print_logs(&logs, use_color), Err(err) => { eprintln!( "failed to load aggregated logs: {err:?}\nfallback: fetching per-server logs..." ); let servers = list_servers(&client, &base_url)?; for snapshot in servers { println!("== {} ==", snapshot.name); let logs = fetch_logs(&client, &base_url, &snapshot.name, opts.limit)?; print_logs(&logs, use_color); println!(); } } } Ok(()) } fn list_servers(client: &Client, base: &str) -> Result<Vec<ServerSnapshot>> { client .get(format!("{base}/servers")) .send() .context("failed to fetch server list")? .error_for_status() .context("server list returned non-success status")? .json::<Vec<ServerSnapshot>>() .context("failed to decode server list json") } fn fetch_logs(client: &Client, base: &str, server: &str, limit: usize) -> Result<Vec<LogLine>> { client .get(format!("{base}/servers/{server}/logs")) .query(&[("limit", limit.to_string())]) .send() .with_context(|| format!("failed to request logs for {server}"))? .error_for_status() .with_context(|| format!("server {server} returned error status"))? .json::<Vec<LogLine>>() .with_context(|| format!("failed to decode log payload for {server}")) } fn fetch_all_logs(client: &Client, base: &str, limit: usize) -> Result<Vec<LogLine>> { client .get(format!("{base}/logs")) .query(&[("limit", limit.to_string())]) .send() .context("failed to request aggregated logs")? .error_for_status() .context("aggregated logs endpoint returned error status")? .json::<Vec<LogLine>>() .context("failed to decode aggregated logs payload") } fn stream_server_logs(server: &str, host: std::net::IpAddr, port: u16, color: bool) -> Result<()> { println!("Streaming logs for {server} (Ctrl+C to stop)..."); let client = Client::builder() .timeout(None) .build() .context("failed to build streaming client")?; let url = format!("http://{host}:{port}/servers/{server}/logs/stream"); let mut backoff = Duration::from_secs(1); loop { match client.get(&url).send() { Ok(response) => match response.error_for_status() { Ok(resp) => { backoff = Duration::from_secs(1); let mut reader = BufReader::new(resp); let mut line = String::new(); while reader.read_line(&mut line)? != 0 { if let Some(payload) = line.trim().strip_prefix("data:") { let trimmed = payload.trim(); if trimmed.is_empty() { line.clear(); continue; } match serde_json::from_str::<LogLine>(trimmed) { Ok(entry) => print_log_line(&entry, color), Err(err) => eprintln!("failed to decode log entry: {err:?}"), } } line.clear(); } eprintln!("log stream closed, reconnecting..."); } Err(err) => { eprintln!("log stream error: {err}; retrying..."); } }, Err(err) => { eprintln!("failed to connect to log stream: {err:?}"); } } thread::sleep(backoff); backoff = (backoff * 2).min(Duration::from_secs(30)); } } fn print_logs(logs: &[LogLine], color: bool) { if logs.is_empty() { println!("(no logs)\n"); return; } for line in logs { print_log_line(line, color); } } fn print_log_line(line: &LogLine, color: bool) { let stream = match line.stream { LogStream::Stdout => "stdout", LogStream::Stderr => "stderr", }; if color { match line.stream { LogStream::Stdout => { println!( "\x1b[38;5;36m[{}][stdout]\x1b[0m {}", line.server, line.line.trim_end() ); } LogStream::Stderr => { println!("🔴 {}", line.line.trim_end()); } } } else { println!("[{}][{}] {}", line.server, stream, line.line.trim_end()); } } ================================================ FILE: src/macos.rs ================================================ //! macOS launchd service management. //! //! Provides tools to list, audit, enable, and disable macOS launch agents and daemons. //! Helps keep the system clean by identifying bloatware and unwanted background processes. use std::collections::HashMap; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use serde::Serialize; use crate::cli::{ MacosAction, MacosAuditOpts, MacosCleanOpts, MacosCommand, MacosDisableOpts, MacosEnableOpts, MacosInfoOpts, MacosListOpts, }; use crate::config::{self, MacosConfig}; /// Service location type. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ServiceType { /// ~/Library/LaunchAgents UserAgent, /// /Library/LaunchAgents SystemAgent, /// /Library/LaunchDaemons SystemDaemon, } impl ServiceType { fn as_str(&self) -> &'static str { match self { ServiceType::UserAgent => "user-agent", ServiceType::SystemAgent => "system-agent", ServiceType::SystemDaemon => "system-daemon", } } fn requires_sudo(&self) -> bool { matches!(self, ServiceType::SystemAgent | ServiceType::SystemDaemon) } fn domain(&self) -> String { match self { ServiceType::UserAgent => format!("gui/{}", get_uid()), ServiceType::SystemAgent | ServiceType::SystemDaemon => "system".to_string(), } } } /// Service category for classification. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ServiceCategory { Apple, Custom, Database, Docker, Vpn, Ai, Bloatware, Development, Unknown, } impl ServiceCategory { fn as_str(&self) -> &'static str { match self { ServiceCategory::Apple => "apple", ServiceCategory::Custom => "custom", ServiceCategory::Database => "database", ServiceCategory::Docker => "docker", ServiceCategory::Vpn => "vpn", ServiceCategory::Ai => "ai", ServiceCategory::Bloatware => "bloatware", ServiceCategory::Development => "development", ServiceCategory::Unknown => "unknown", } } } /// Represents a discovered launchd service. #[derive(Debug, Clone, Serialize)] pub struct LaunchdService { /// Service identifier (e.g., com.apple.Finder). pub id: String, /// Path to the plist file. pub plist_path: PathBuf, /// Whether the service is currently loaded. pub loaded: bool, /// Whether the service is currently running. pub running: bool, /// Process ID if running. pub pid: Option<u32>, /// Service type (user agent, system agent, system daemon). pub service_type: ServiceType, /// Service category. pub category: ServiceCategory, /// Program or ProgramArguments from plist. pub program: Option<String>, } /// Audit recommendation for a service. #[derive(Debug, Clone, Serialize)] pub struct AuditRecommendation { pub service: LaunchdService, pub action: RecommendedAction, pub reason: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RecommendedAction { Keep, Disable, Review, } pub fn run(cmd: MacosCommand) -> Result<()> { match cmd.action { Some(MacosAction::List(opts)) => run_list(opts), Some(MacosAction::Status) => run_status(), Some(MacosAction::Audit(opts)) => run_audit(opts), Some(MacosAction::Info(opts)) => run_info(opts), Some(MacosAction::Disable(opts)) => run_disable(opts), Some(MacosAction::Enable(opts)) => run_enable(opts), Some(MacosAction::Clean(opts)) => run_clean(opts), None => run_status(), } } fn run_list(opts: MacosListOpts) -> Result<()> { let services = discover_services()?; let filtered: Vec<_> = services .into_iter() .filter(|s| { if opts.user && !matches!(s.service_type, ServiceType::UserAgent) { return false; } if opts.system && matches!(s.service_type, ServiceType::UserAgent) { return false; } true }) .collect(); if opts.json { println!("{}", serde_json::to_string_pretty(&filtered)?); return Ok(()); } println!("Discovered {} services\n", filtered.len()); // Group by type let mut by_type: HashMap<&str, Vec<&LaunchdService>> = HashMap::new(); for svc in &filtered { by_type .entry(svc.service_type.as_str()) .or_default() .push(svc); } for (type_name, services) in by_type.iter() { println!("{}:", type_name); for svc in services { let status = if svc.running { format!("running (pid {})", svc.pid.unwrap_or(0)) } else if svc.loaded { "loaded".to_string() } else { "disabled".to_string() }; println!(" {} [{}] - {}", svc.id, svc.category.as_str(), status); } println!(); } Ok(()) } fn run_status() -> Result<()> { let services = discover_services()?; // Filter to non-Apple running services let running: Vec<_> = services .iter() .filter(|s| s.running && s.category != ServiceCategory::Apple) .collect(); if running.is_empty() { println!("No non-Apple services currently running."); return Ok(()); } println!("Running non-Apple services:\n"); for svc in running { let pid_str = svc.pid.map(|p| format!(" (pid {})", p)).unwrap_or_default(); println!(" {} [{}]{}", svc.id, svc.category.as_str(), pid_str); if let Some(prog) = &svc.program { println!(" {}", prog); } } Ok(()) } fn run_audit(opts: MacosAuditOpts) -> Result<()> { let services = discover_services()?; let macos_config = load_macos_config(); let recommendations = audit_services(&services, &macos_config); if opts.json { println!("{}", serde_json::to_string_pretty(&recommendations)?); return Ok(()); } let to_disable: Vec<_> = recommendations .iter() .filter(|r| r.action == RecommendedAction::Disable) .collect(); let to_review: Vec<_> = recommendations .iter() .filter(|r| r.action == RecommendedAction::Review) .collect(); if to_disable.is_empty() && to_review.is_empty() { println!("All services look good! No recommendations."); return Ok(()); } if !to_disable.is_empty() { println!("Recommended to DISABLE ({}):\n", to_disable.len()); for rec in &to_disable { println!(" {} [{}]", rec.service.id, rec.service.category.as_str()); println!(" Reason: {}", rec.reason); } println!(); } if !to_review.is_empty() { println!("Recommended to REVIEW ({}):\n", to_review.len()); for rec in &to_review { println!(" {} [{}]", rec.service.id, rec.service.category.as_str()); println!(" Reason: {}", rec.reason); } println!(); } if !to_disable.is_empty() { println!( "Run `f macos clean` to disable {} bloatware services.", to_disable.len() ); } Ok(()) } fn run_info(opts: MacosInfoOpts) -> Result<()> { let services = discover_services()?; let svc = services .iter() .find(|s| s.id == opts.service) .ok_or_else(|| anyhow::anyhow!("Service '{}' not found", opts.service))?; println!("Service: {}", svc.id); println!("Type: {}", svc.service_type.as_str()); println!("Category:{}", svc.category.as_str()); println!("Plist: {}", svc.plist_path.display()); println!("Loaded: {}", svc.loaded); println!("Running: {}", svc.running); if let Some(pid) = svc.pid { println!("PID: {}", pid); } if let Some(prog) = &svc.program { println!("Program: {}", prog); } // Show plist content println!("\nPlist contents:"); if let Ok(content) = std::fs::read_to_string(&svc.plist_path) { println!("{}", content); } Ok(()) } fn run_disable(opts: MacosDisableOpts) -> Result<()> { let services = discover_services()?; let svc = services .iter() .find(|s| s.id == opts.service) .ok_or_else(|| anyhow::anyhow!("Service '{}' not found", opts.service))?; if svc.category == ServiceCategory::Apple { bail!( "Refusing to disable Apple service '{}'. This could break your system.", svc.id ); } if !opts.yes { print!("Disable service '{}'? [y/N] ", svc.id); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; if !input.trim().eq_ignore_ascii_case("y") { println!("Cancelled."); return Ok(()); } } disable_service(svc)?; println!("Disabled service '{}'", svc.id); Ok(()) } fn run_enable(opts: MacosEnableOpts) -> Result<()> { let services = discover_services()?; let svc = services .iter() .find(|s| s.id == opts.service) .ok_or_else(|| anyhow::anyhow!("Service '{}' not found", opts.service))?; enable_service(svc)?; println!("Enabled service '{}'", svc.id); Ok(()) } fn run_clean(opts: MacosCleanOpts) -> Result<()> { let services = discover_services()?; let macos_config = load_macos_config(); let recommendations = audit_services(&services, &macos_config); let to_disable: Vec<_> = recommendations .iter() .filter(|r| r.action == RecommendedAction::Disable) .collect(); if to_disable.is_empty() { println!("No bloatware services found to clean."); return Ok(()); } println!("Services to disable ({}):\n", to_disable.len()); for rec in &to_disable { println!(" {} - {}", rec.service.id, rec.reason); } println!(); if opts.dry_run { println!("Dry run - no changes made."); return Ok(()); } if !opts.yes && io::stdin().is_terminal() { print!("Disable these {} services? [y/N] ", to_disable.len()); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; if !input.trim().eq_ignore_ascii_case("y") { println!("Cancelled."); return Ok(()); } } let mut disabled = 0; let mut failed = 0; for rec in to_disable { match disable_service(&rec.service) { Ok(()) => { println!("Disabled: {}", rec.service.id); disabled += 1; } Err(e) => { eprintln!("Failed to disable {}: {}", rec.service.id, e); failed += 1; } } } println!("\nDisabled {} services, {} failed.", disabled, failed); Ok(()) } /// Discover all launchd services from the standard locations. fn discover_services() -> Result<Vec<LaunchdService>> { let mut services = Vec::new(); // User agents if let Some(home) = dirs::home_dir() { let user_agents = home.join("Library/LaunchAgents"); if user_agents.exists() { discover_in_dir(&user_agents, ServiceType::UserAgent, &mut services)?; } } // System agents let system_agents = Path::new("/Library/LaunchAgents"); if system_agents.exists() { discover_in_dir(system_agents, ServiceType::SystemAgent, &mut services)?; } // System daemons let system_daemons = Path::new("/Library/LaunchDaemons"); if system_daemons.exists() { discover_in_dir(system_daemons, ServiceType::SystemDaemon, &mut services)?; } // Enrich with launchctl status enrich_with_launchctl_status(&mut services); Ok(services) } fn discover_in_dir( dir: &Path, service_type: ServiceType, services: &mut Vec<LaunchdService>, ) -> Result<()> { let entries = std::fs::read_dir(dir) .with_context(|| format!("Failed to read directory: {}", dir.display()))?; for entry in entries.flatten() { let path = entry.path(); if path.extension().map(|e| e == "plist").unwrap_or(false) { if let Some(svc) = parse_plist(&path, service_type) { services.push(svc); } } } Ok(()) } /// Parse a plist file and extract service information. fn parse_plist(path: &Path, service_type: ServiceType) -> Option<LaunchdService> { // Use plutil to convert to JSON let output = Command::new("plutil") .args(["-convert", "json", "-o", "-"]) .arg(path) .output() .ok()?; if !output.status.success() { return None; } let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; let label = json.get("Label")?.as_str()?.to_string(); let program = json .get("Program") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| { json.get("ProgramArguments") .and_then(|v| v.as_array()) .and_then(|arr| arr.first()) .and_then(|v| v.as_str()) .map(|s| s.to_string()) }); let category = categorize_service(&label); Some(LaunchdService { id: label, plist_path: path.to_path_buf(), loaded: false, running: false, pid: None, service_type, category, program, }) } /// Enrich services with launchctl status information. fn enrich_with_launchctl_status(services: &mut [LaunchdService]) { // Query user domain let uid = get_uid(); if let Ok(output) = Command::new("launchctl").args(["list"]).output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); parse_launchctl_list(&stdout, services); } } // For system services, we need to check separately // (requires sudo for full info, but we can get some info without) if let Ok(_output) = Command::new("launchctl") .args(["print", &format!("gui/{}", uid)]) .output() { // Parse the print output for more detailed status // (This is optional and provides additional detail) } } fn parse_launchctl_list(output: &str, services: &mut [LaunchdService]) { for line in output.lines().skip(1) { // Format: PID Status Label let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 3 { let pid_str = parts[0]; let label = parts[2]; if let Some(svc) = services.iter_mut().find(|s| s.id == label) { svc.loaded = true; if pid_str != "-" { if let Ok(pid) = pid_str.parse::<u32>() { svc.running = true; svc.pid = Some(pid); } } } } } } /// Categorize a service based on its identifier. fn categorize_service(label: &str) -> ServiceCategory { if label.starts_with("com.apple.") { return ServiceCategory::Apple; } // Known bloatware patterns if is_known_bloatware(label) { return ServiceCategory::Bloatware; } // Database services if label.contains("postgres") || label.contains("mysql") || label.contains("redis") || label.contains("mongo") { return ServiceCategory::Database; } // Docker if label.contains("docker") || label.contains("orbstack") { return ServiceCategory::Docker; } // VPN if label.contains("vpn") || label.contains("wireguard") || label.contains("tailscale") || label.contains("nordvpn") { return ServiceCategory::Vpn; } // AI tools if label.contains("lmstudio") || label.contains("ollama") || label.contains("copilot") { return ServiceCategory::Ai; } // Development if label.contains("homebrew") || label.contains("nix") || label.contains("watchman") || label.contains("github") { return ServiceCategory::Development; } ServiceCategory::Unknown } /// Check if a service is known bloatware. fn is_known_bloatware(label: &str) -> bool { let bloatware_patterns = [ "com.google.keystone", "com.google.GoogleUpdater", "com.adobe.ARMDC", "com.adobe.ARMDCHelper", "com.adobe.AdobeCreativeCloud", "com.adobe.acc", "us.zoom.ZoomDaemon", "us.zoom.updater", "com.microsoft.update", "com.microsoft.autoupdate", "com.dropbox.", "com.spotify.webhelper", "com.valvesoftware.steam", "com.skype.", "com.slack.update", ]; for pattern in bloatware_patterns { if label.starts_with(pattern) || label.contains(pattern) { return true; } } false } /// Audit services and generate recommendations. fn audit_services( services: &[LaunchdService], config: &Option<MacosConfig>, ) -> Vec<AuditRecommendation> { let mut recommendations = Vec::new(); for svc in services { // Skip Apple services if svc.category == ServiceCategory::Apple { continue; } // Check if explicitly allowed if let Some(cfg) = config { if is_pattern_match(&svc.id, &cfg.allowed) { continue; } } // Check if explicitly blocked if let Some(cfg) = config { if is_pattern_match(&svc.id, &cfg.blocked) { recommendations.push(AuditRecommendation { service: svc.clone(), action: RecommendedAction::Disable, reason: "Matched blocked pattern in flow.toml".to_string(), }); continue; } } // Known bloatware if svc.category == ServiceCategory::Bloatware { recommendations.push(AuditRecommendation { service: svc.clone(), action: RecommendedAction::Disable, reason: "Known bloatware/updater service".to_string(), }); continue; } // Unknown services that are running if svc.category == ServiceCategory::Unknown && svc.running { recommendations.push(AuditRecommendation { service: svc.clone(), action: RecommendedAction::Review, reason: "Unknown running service".to_string(), }); } } recommendations } /// Check if a label matches any of the patterns. fn is_pattern_match(label: &str, patterns: &[String]) -> bool { for pattern in patterns { if pattern.ends_with('*') { let prefix = &pattern[..pattern.len() - 1]; if label.starts_with(prefix) { return true; } } else if label == pattern { return true; } } false } /// Disable a launchd service. fn disable_service(svc: &LaunchdService) -> Result<()> { let domain = svc.service_type.domain(); // First bootout (unload) if svc.loaded { let target = format!("{}/{}", domain, svc.id); let mut cmd = if svc.service_type.requires_sudo() { let mut c = Command::new("sudo"); c.args(["launchctl", "bootout", &target]); c } else { let mut c = Command::new("launchctl"); c.args(["bootout", &target]); c }; let output = cmd.output()?; if !output.status.success() { // Bootout may fail if not loaded, continue to disable let stderr = String::from_utf8_lossy(&output.stderr); if !stderr.contains("No such process") && !stderr.contains("Could not find service") { // Log but don't fail - the service might already be unloaded tracing::debug!("bootout warning: {}", stderr); } } } // Then disable let target = format!("{}/{}", domain, svc.id); let mut cmd = if svc.service_type.requires_sudo() { let mut c = Command::new("sudo"); c.args(["launchctl", "disable", &target]); c } else { let mut c = Command::new("launchctl"); c.args(["disable", &target]); c }; let output = cmd.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Failed to disable service: {}", stderr); } Ok(()) } /// Enable a launchd service. fn enable_service(svc: &LaunchdService) -> Result<()> { let domain = svc.service_type.domain(); // First enable let target = format!("{}/{}", domain, svc.id); let mut cmd = if svc.service_type.requires_sudo() { let mut c = Command::new("sudo"); c.args(["launchctl", "enable", &target]); c } else { let mut c = Command::new("launchctl"); c.args(["enable", &target]); c }; let output = cmd.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Failed to enable service: {}", stderr); } // Then bootstrap (load) let mut cmd = if svc.service_type.requires_sudo() { let mut c = Command::new("sudo"); c.args(["launchctl", "bootstrap", &domain]); c.arg(&svc.plist_path); c } else { let mut c = Command::new("launchctl"); c.args(["bootstrap", &domain]); c.arg(&svc.plist_path); c }; let output = cmd.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // Bootstrap may fail if already loaded if !stderr.contains("already loaded") && !stderr.contains("service already loaded") { bail!("Failed to bootstrap service: {}", stderr); } } Ok(()) } /// Get the current user's UID. fn get_uid() -> u32 { unsafe { libc::getuid() } } /// Load macOS config from global flow.toml. fn load_macos_config() -> Option<MacosConfig> { let config_path = config::default_config_path(); if !config_path.exists() { return None; } let cfg = config::load_or_default(&config_path); cfg.macos } #[cfg(test)] mod tests { use super::*; #[test] fn test_categorize_apple() { assert_eq!( categorize_service("com.apple.Finder"), ServiceCategory::Apple ); } #[test] fn test_categorize_bloatware() { assert_eq!( categorize_service("com.google.keystone.agent"), ServiceCategory::Bloatware ); assert_eq!( categorize_service("com.adobe.ARMDC.Agent"), ServiceCategory::Bloatware ); } #[test] fn test_is_known_bloatware() { assert!(is_known_bloatware("com.google.keystone.agent")); assert!(is_known_bloatware("com.adobe.ARMDCHelper.plist")); assert!(is_known_bloatware("us.zoom.ZoomDaemon")); assert!(!is_known_bloatware("com.apple.Finder")); } #[test] fn test_pattern_match() { let patterns = vec!["com.nikiv.*".to_string(), "exact.match".to_string()]; assert!(is_pattern_match("com.nikiv.service", &patterns)); assert!(is_pattern_match("com.nikiv.other", &patterns)); assert!(is_pattern_match("exact.match", &patterns)); assert!(!is_pattern_match("com.other.service", &patterns)); } } ================================================ FILE: src/main.rs ================================================ use std::net::IpAddr; use std::path::Path; use std::time::Instant; use anyhow::{Result, bail}; use clap::{Parser, error::ErrorKind}; use flowd::{ agents, ai, ai_test, analytics, archive, auth, branches, changes, cli::{ Cli, Commands, InstallAction, ProxyAction, ProxyCommand, RerunOpts, ReviewAction, ShellAction, ShellCommand, TaskRunOpts, TasksOpts, TraceAction, }, code, commit, commits, daemon, deploy, deps, docs, doctor, domains, env, explain_commits, ext, fish_install, fish_trace, fix, fixup, git_guard, gitignore_policy, hash, health, help_search, history, hive, home, hub, info, init, init_tracing, install, invariants, jj, latest, lifecycle, log_server, macos, notify, otp, palette, parallel, processes, projects, proxy, publish, push, recipe, registry, release, repos, reviews_todo, seq_rpc, services, setup, skills, ssh_keys, storage, supervisor, sync, task_match, tasks, todo, tools, traces, undo, upgrade, upstream, url_inspect, usage, web, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct StartupPolicy { load_global_secrets: bool, sync_skills: bool, } impl StartupPolicy { const NONE: Self = Self { load_global_secrets: false, sync_skills: false, }; const SECRETS_ONLY: Self = Self { load_global_secrets: true, sync_skills: false, }; const FULL: Self = Self { load_global_secrets: true, sync_skills: true, }; } fn main() -> Result<()> { init_tracing(); let raw_args: Vec<String> = std::env::args().collect(); let analytics_capture = usage::command_capture(&raw_args); let is_analytics_command = usage::is_analytics_command(&raw_args); let started_at = Instant::now(); let result = (|| -> Result<()> { // Handle `f ?` for fuzzy help search before clap parsing if raw_args.get(1).map(|s| s.as_str()) == Some("?") { return help_search::run(); } // Handle --help-full early for instant output if raw_args.iter().any(|s| s == "--help-full") { return help_search::print_full_json(); } let cli = match Cli::try_parse_from(&raw_args) { Ok(cli) => cli, Err(err) => { if matches!( err.kind(), ErrorKind::UnknownArgument | ErrorKind::InvalidSubcommand ) { // Fallback: treat first positional as task name and rest as args. let mut iter = raw_args.into_iter(); let _bin = iter.next(); if let Some(task_name) = iter.next() { let args: Vec<String> = iter.collect(); apply_startup_policy(StartupPolicy::SECRETS_ONLY); return tasks::run_with_discovery(&task_name, args); } } err.exit() } }; apply_startup_policy(startup_policy_for(cli.command.as_ref())); match cli.command { Some(Commands::Hub(cmd)) => { hub::run(cmd)?; } Some(Commands::Init(opts)) => { init::run(opts)?; } Some(Commands::ShellInit(opts)) => { shell_init(&opts.shell); } Some(Commands::Shell(cmd)) => { shell_command(cmd); } Some(Commands::New(opts)) => { code::new_from_template(opts)?; } Some(Commands::Home(cmd)) => { home::run(cmd)?; } Some(Commands::Archive(opts)) => { archive::run(opts)?; } Some(Commands::Doctor(opts)) => { doctor::run(opts)?; } Some(Commands::Health(opts)) => { health::run(opts)?; } Some(Commands::Invariants(opts)) => { let root = std::env::current_dir()?; invariants::check(&root, opts.staged)?; } Some(Commands::Tasks(cmd)) => { tasks::run_tasks_command(cmd)?; } Some(Commands::Fast(opts)) => { tasks::run_fast(opts)?; } Some(Commands::Up(opts)) => { lifecycle::run_up(opts)?; } Some(Commands::Down(opts)) => { lifecycle::run_down(opts)?; } Some(Commands::AiTestNew(opts)) => { ai_test::run(opts)?; } Some(Commands::Global(cmd)) => { tasks::run_global(cmd)?; } Some(Commands::Run(opts)) => { tasks::run(opts)?; } Some(Commands::Search) => { palette::run_global()?; } Some(Commands::LastCmd) => { // Prefer fish shell traces if available, fall back to flow history if fish_trace::load_last_record()?.is_some() { fish_trace::print_last_fish_cmd()?; } else { history::print_last_record()?; } } Some(Commands::LastCmdFull) => { // Prefer fish shell traces if available, fall back to flow history if fish_trace::load_last_record()?.is_some() { fish_trace::print_last_fish_cmd_full()?; } else { history::print_last_record_full()?; } } Some(Commands::FishLast) => { fish_trace::print_last_fish_cmd()?; } Some(Commands::FishLastFull) => { fish_trace::print_last_fish_cmd_full()?; } Some(Commands::FishInstall(opts)) => { fish_install::run(opts)?; } Some(Commands::Rerun(opts)) => { rerun(opts)?; } Some(Commands::Ps(opts)) => { processes::show_project_processes(opts)?; } Some(Commands::Kill(opts)) => { processes::kill_processes(opts)?; } Some(Commands::Logs(opts)) => { processes::show_task_logs(opts)?; } Some(Commands::Trace(cmd)) => { if let Some(action) = cmd.action { match action { TraceAction::Session(opts) => { traces::run_session(opts)?; } } } else { traces::run(cmd.events)?; } } Some(Commands::Projects) => { projects::show_projects()?; } Some(Commands::Sessions(opts)) => { ai::run_sessions(&opts)?; } Some(Commands::Active(opts)) => { projects::handle_active(opts)?; } Some(Commands::Server(opts)) => { log_server::run(opts)?; } Some(Commands::Web(opts)) => { web::run(opts)?; } Some(Commands::Match(opts)) => { task_match::run(task_match::MatchOpts { args: opts.query, model: opts.model, port: Some(opts.port), execute: !opts.dry_run, })?; } Some(Commands::Ask(opts)) => { flowd::ask::run(flowd::ask::AskOpts { args: opts.query, model: opts.model, url: opts.url, })?; } Some(Commands::Branches(cmd)) => { branches::run(cmd)?; } Some(Commands::Status(opts)) => { jj::run_workflow_status(opts.raw)?; } Some(Commands::Commit(opts)) => { // Default: fast commit lane with deferred Codex deep review. let mut force = opts.force || opts.approved; let mut message_arg = opts.message_arg.as_deref(); let mut open_review = opts.review; if !force { if let Some(arg) = message_arg { if arg == "force" && opts.message.is_none() && opts.fast.is_none() && !opts.queue && !opts.no_queue { force = true; message_arg = None; } else if arg == "review" && opts.message.is_none() && opts.fast.is_none() && !opts.queue && !opts.no_queue { open_review = true; message_arg = None; } } } let queue = commit::resolve_commit_queue_mode(opts.queue, opts.no_queue || force) .with_open_review(open_review); let push = !opts.no_push; let explicit_blocking = opts.slow || opts.dry || opts.context || opts.sync || opts.codex || opts.review_model.is_some() || opts.skip_quality || opts.skip_docs || opts.skip_tests || opts.message.is_some() || message_arg.is_some(); let implicit_quick = !opts.quick && !explicit_blocking && opts.fast.is_none() && commit::commit_quick_default_enabled(); if opts.quick || implicit_quick { if implicit_quick { println!( "ℹ️ using fast commit + deferred Codex deep review by default. Pass --slow for blocking pre-commit review." ); } commit::run_quick_then_async_review( push, queue, opts.hashed, &opts.paths, opts.fast.as_deref(), )?; return Ok(()); } if let Some(message) = opts.fast.as_deref() { commit::run_fast(message, push, queue, opts.hashed, &opts.paths)?; return Ok(()); } let review_selection = commit::resolve_review_selection_v2(opts.codex, opts.review_model.clone()); let author_message = opts.message.as_deref().or(message_arg); if opts.dry { commit::dry_run_context()?; } else if opts.sync { commit::run_with_check_sync( push, opts.context, review_selection, author_message, opts.tokens, true, queue, opts.hashed, &opts.paths, commit::CommitGateOverrides { skip_quality: opts.skip_quality, skip_docs: opts.skip_docs, skip_tests: opts.skip_tests, }, )?; } else { commit::run_with_check_with_gitedit( push, opts.context, review_selection, author_message, opts.tokens, queue, opts.hashed, &opts.paths, commit::CommitGateOverrides { skip_quality: opts.skip_quality, skip_docs: opts.skip_docs, skip_tests: opts.skip_tests, }, )?; } } Some(Commands::CommitQueue(cmd)) => { commit::run_commit_queue(cmd)?; } Some(Commands::ReviewsTodo(cmd)) => { reviews_todo::run(cmd)?; } Some(Commands::Pr(opts)) => { commit::run_pr(opts)?; } Some(Commands::Gitignore(cmd)) => { gitignore_policy::run(cmd)?; } Some(Commands::Recipe(cmd)) => { recipe::run(cmd)?; } Some(Commands::Review(cmd)) => match cmd.action { Some(ReviewAction::Latest) | None => { commit::open_latest_queue_review()?; } Some(ReviewAction::Copy { hash }) => { commit::copy_review_prompt(hash.as_deref())?; } }, Some(Commands::GitRepair(opts)) => { git_guard::run_git_repair(opts)?; } Some(Commands::Jj(cmd)) => { jj::run(cmd)?; } Some(Commands::CommitSimple(opts)) => { // Simple commit without review - always sync (fast, no hub) let mut force = opts.force || opts.approved; let mut open_review = opts.review; if !force { if let Some(arg) = opts.message_arg.as_deref() { if arg == "force" && opts.message.is_none() && opts.fast.is_none() { force = true; } else if arg == "review" && opts.message.is_none() && opts.fast.is_none() && !opts.queue && !opts.no_queue { open_review = true; } } } let queue = commit::resolve_commit_queue_mode(opts.queue, opts.no_queue || force) .with_open_review(open_review); let push = !opts.no_push; commit::run_sync(push, queue, opts.hashed, &opts.paths)?; } Some(Commands::CommitWithCheck(opts)) => { // Review but no gitedit sync let mut force = opts.force || opts.approved; let mut open_review = opts.review; if !force { if let Some(arg) = opts.message_arg.as_deref() { if arg == "force" && opts.message.is_none() && opts.fast.is_none() { force = true; } else if arg == "review" && opts.message.is_none() && opts.fast.is_none() && !opts.queue && !opts.no_queue { open_review = true; } } } let queue = commit::resolve_commit_queue_mode(opts.queue, opts.no_queue || force) .with_open_review(open_review); let push = !opts.no_push; if opts.quick { commit::run_quick_then_async_review( push, queue, opts.hashed, &opts.paths, opts.fast.as_deref(), )?; return Ok(()); } let review_selection = commit::resolve_review_selection_v2(opts.codex, opts.review_model.clone()); if opts.dry { commit::dry_run_context()?; } else if opts.sync { commit::run_with_check_sync( push, opts.context, review_selection, opts.message.as_deref(), opts.tokens, false, queue, opts.hashed, &opts.paths, commit::CommitGateOverrides { skip_quality: opts.skip_quality, skip_docs: opts.skip_docs, skip_tests: opts.skip_tests, }, )?; } else { commit::run_with_check( push, opts.context, review_selection, opts.message.as_deref(), opts.tokens, queue, opts.hashed, &opts.paths, commit::CommitGateOverrides { skip_quality: opts.skip_quality, skip_docs: opts.skip_docs, skip_tests: opts.skip_tests, }, )?; } } Some(Commands::Fix(opts)) => { fix::run(opts)?; } Some(Commands::Undo(cmd)) => { undo::run(cmd)?; } Some(Commands::Fixup(opts)) => { fixup::run(opts)?; } Some(Commands::Changes(cmd)) => { changes::run(cmd)?; } Some(Commands::Diff(cmd)) => { changes::run_diff(cmd)?; } Some(Commands::Hash(opts)) => { hash::run(opts)?; } Some(Commands::Daemon(cmd)) => { daemon::run(cmd)?; } Some(Commands::Supervisor(cmd)) => { supervisor::run(cmd)?; } Some(Commands::Ai(cmd)) => { ai::run(cmd.action)?; } Some(Commands::Codex { action }) => { ai::run_provider(ai::Provider::Codex, action)?; } Some(Commands::Cursor { action }) => { ai::run_provider(ai::Provider::Cursor, action)?; } Some(Commands::Claude { action }) => { ai::run_provider(ai::Provider::Claude, action)?; } Some(Commands::Env(cmd)) => { env::run(cmd.action)?; } Some(Commands::Otp(cmd)) => { otp::run(cmd)?; } Some(Commands::Auth(opts)) => { auth::run(opts)?; } Some(Commands::Services(cmd)) => { services::run(cmd)?; } Some(Commands::Macos(cmd)) => { macos::run(cmd)?; } Some(Commands::Ssh(cmd)) => { ssh_keys::run(cmd.action)?; } Some(Commands::Todo(cmd)) => { todo::run(cmd)?; } Some(Commands::Ext(cmd)) => { ext::run(cmd)?; } Some(Commands::Skills(cmd)) => { skills::run(cmd)?; } Some(Commands::Url(cmd)) => { url_inspect::run(cmd)?; } Some(Commands::Deps(cmd)) => { deps::run(cmd)?; } Some(Commands::Db(cmd)) => { storage::run(cmd)?; } Some(Commands::Tools(cmd)) => { tools::run(cmd)?; } Some(Commands::Notify(cmd)) => { notify::run(cmd)?; } Some(Commands::Commits(cmd)) => { commits::run(cmd)?; } Some(Commands::SeqRpc(cmd)) => { seq_rpc::run(cmd)?; } Some(Commands::ExplainCommits(cmd)) => { explain_commits::run_cli(cmd)?; } Some(Commands::Setup(opts)) => { setup::run(opts)?; } Some(Commands::Agents(cmd)) => { agents::run(cmd)?; } Some(Commands::Hive(cmd)) => { hive::run_command(cmd)?; } Some(Commands::Sync(cmd)) => { sync::run(cmd)?; } Some(Commands::Checkout(cmd)) => { sync::run_checkout(cmd)?; } Some(Commands::Switch(cmd)) => { sync::run_switch(cmd)?; } Some(Commands::Push(cmd)) => { push::run(cmd)?; } Some(Commands::Info) => { info::run()?; } Some(Commands::Upstream(cmd)) => { upstream::run(cmd)?; } Some(Commands::Deploy(cmd)) => { deploy::run(cmd)?; } Some(Commands::Prod(cmd)) => { deploy::run_prod(cmd)?; } Some(Commands::Publish(cmd)) => { publish::run(cmd)?; } Some(Commands::Clone(opts)) => { repos::clone_git_like(opts)?; } Some(Commands::Repos(cmd)) => { repos::run(cmd)?; } Some(Commands::Code(cmd)) => { code::run(cmd)?; } Some(Commands::Migrate(cmd)) => { code::run_migrate(cmd)?; } Some(Commands::Parallel(cmd)) => { parallel::run(cmd)?; } Some(Commands::Docs(cmd)) => { docs::run(cmd)?; } Some(Commands::Upgrade(opts)) => { upgrade::run(opts)?; } Some(Commands::Latest) => { latest::run()?; } Some(Commands::Release(cmd)) => { release::run(cmd)?; } Some(Commands::Install(cmd)) => { if let Some(InstallAction::Index(opts)) = cmd.action.clone() { install::run_index(opts)?; } else { install::run(cmd.opts)?; } } Some(Commands::Registry(cmd)) => { registry::run(cmd)?; } Some(Commands::Analytics(cmd)) => { analytics::run(cmd)?; } Some(Commands::Proxy(cmd)) => { proxy_command(cmd)?; } Some(Commands::Domains(cmd)) => { domains::run(cmd)?; } Some(Commands::TaskShortcut(args)) => { let Some(task_name) = args.first() else { bail!("no task name provided"); }; if let Err(err) = tasks::run_with_discovery(task_name, args[1..].to_vec()) { if is_task_not_found(&err) { return Err(err); } return Err(err); } } None => { palette::run(TasksOpts::default())?; } } Ok(()) })(); usage::record_command_result(&analytics_capture, started_at.elapsed(), &result); usage::maybe_prompt_for_opt_in(is_analytics_command, result.is_ok()); result } fn apply_startup_policy(policy: StartupPolicy) { let policy = apply_startup_env_overrides(policy); if policy.load_global_secrets { flowd::config::load_global_secrets(); } if policy.sync_skills { skills::auto_sync_skills(); } } fn apply_startup_env_overrides(mut policy: StartupPolicy) -> StartupPolicy { if let Some(value) = env_truthy_override("FLOW_STARTUP_LOAD_GLOBAL_SECRETS") { policy.load_global_secrets = value; } if let Some(value) = env_truthy_override("FLOW_STARTUP_SYNC_SKILLS") { policy.sync_skills = value; } policy } fn env_truthy_override(key: &str) -> Option<bool> { let value = std::env::var(key).ok()?; let normalized = value.trim().to_ascii_lowercase(); match normalized.as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, } } fn startup_policy_for(command: Option<&Commands>) -> StartupPolicy { use flowd::cli::{AnalyticsAction, GlobalAction, ProxyAction, ReposAction, TasksAction}; match command { None => StartupPolicy::NONE, Some(Commands::Search) => StartupPolicy::NONE, Some(Commands::ShellInit(_)) => StartupPolicy::NONE, Some(Commands::Shell(_)) => StartupPolicy::NONE, Some(Commands::Init(_)) => StartupPolicy::NONE, Some(Commands::New(_)) => StartupPolicy::NONE, Some(Commands::Archive(_)) => StartupPolicy::NONE, Some(Commands::Doctor(_)) => StartupPolicy::NONE, Some(Commands::Health(_)) => StartupPolicy::NONE, Some(Commands::Invariants(_)) => StartupPolicy::NONE, Some(Commands::Projects) => StartupPolicy::NONE, Some(Commands::Active(_)) => StartupPolicy::NONE, Some(Commands::LastCmd) => StartupPolicy::NONE, Some(Commands::LastCmdFull) => StartupPolicy::NONE, Some(Commands::FishLast) => StartupPolicy::NONE, Some(Commands::FishLastFull) => StartupPolicy::NONE, Some(Commands::FishInstall(_)) => StartupPolicy::NONE, Some(Commands::Ps(_)) => StartupPolicy::NONE, Some(Commands::Logs(_)) => StartupPolicy::NONE, Some(Commands::Trace(_)) => StartupPolicy::NONE, Some(Commands::Branches(_)) => StartupPolicy::NONE, Some(Commands::Status(_)) => StartupPolicy::NONE, Some(Commands::Changes(_)) => StartupPolicy::NONE, Some(Commands::Diff(_)) => StartupPolicy::NONE, Some(Commands::Hash(_)) => StartupPolicy::NONE, Some(Commands::Daemon(_)) => StartupPolicy::NONE, Some(Commands::Supervisor(_)) => StartupPolicy::NONE, Some(Commands::Macos(_)) => StartupPolicy::NONE, Some(Commands::Ssh(_)) => StartupPolicy::NONE, Some(Commands::Todo(_)) => StartupPolicy::NONE, Some(Commands::Ext(_)) => StartupPolicy::NONE, Some(Commands::Tools(_)) => StartupPolicy::NONE, Some(Commands::Notify(_)) => StartupPolicy::NONE, Some(Commands::Commits(_)) => StartupPolicy::NONE, Some(Commands::SeqRpc(_)) => StartupPolicy::NONE, Some(Commands::ExplainCommits(_)) => StartupPolicy::NONE, Some(Commands::Info) => StartupPolicy::NONE, Some(Commands::Upstream(_)) => StartupPolicy::NONE, Some(Commands::Latest) => StartupPolicy::NONE, Some(Commands::Url(_)) => StartupPolicy::SECRETS_ONLY, Some(Commands::Analytics(cmd)) => match cmd.action.as_ref() { None | Some(&AnalyticsAction::Status) | Some(&AnalyticsAction::Enable) | Some(&AnalyticsAction::Disable) | Some(&AnalyticsAction::Export) | Some(&AnalyticsAction::Purge) => StartupPolicy::NONE, }, Some(Commands::Tasks(cmd)) => match cmd.action.as_ref() { None | Some(TasksAction::List(_)) | Some(TasksAction::Dupes(_)) | Some(TasksAction::InitAi(_)) | Some(TasksAction::Daemon(_)) => StartupPolicy::NONE, Some(TasksAction::BuildAi(_)) | Some(TasksAction::RunAi(_)) => { StartupPolicy::SECRETS_ONLY } }, Some(Commands::Global(cmd)) => match (cmd.action.as_ref(), cmd.list, cmd.task.as_ref()) { (Some(GlobalAction::List), _, _) | (None, true, _) | (None, false, None) => { StartupPolicy::NONE } _ => StartupPolicy::SECRETS_ONLY, }, Some(Commands::Sessions(opts)) => { if opts.summarize || opts.handoff { StartupPolicy::SECRETS_ONLY } else { StartupPolicy::NONE } } Some(Commands::Proxy(cmd)) => match &cmd.action { ProxyAction::Trace(_) | ProxyAction::Last(_) | ProxyAction::Add(_) | ProxyAction::List | ProxyAction::Stop => StartupPolicy::NONE, ProxyAction::Start(_) => StartupPolicy::SECRETS_ONLY, }, Some(Commands::Repos(cmd)) => match cmd.action.as_ref() { None | Some(ReposAction::Capsule(_)) | Some(ReposAction::Alias(_)) => { StartupPolicy::NONE } _ => StartupPolicy::SECRETS_ONLY, }, Some(Commands::Ai(_)) => StartupPolicy::FULL, Some(Commands::Codex { .. }) => StartupPolicy::FULL, Some(Commands::Cursor { .. }) => StartupPolicy::FULL, Some(Commands::Claude { .. }) => StartupPolicy::FULL, Some(Commands::Commit(_)) | Some(Commands::CommitQueue(_)) | Some(Commands::CommitSimple(_)) | Some(Commands::CommitWithCheck(_)) | Some(Commands::Fix(_)) | Some(Commands::Fixup(_)) | Some(Commands::Skills(_)) | Some(Commands::Setup(_)) => StartupPolicy::FULL, Some(Commands::Run(_)) | Some(Commands::Fast(_)) | Some(Commands::Up(_)) | Some(Commands::Down(_)) | Some(Commands::Rerun(_)) | Some(Commands::Kill(_)) | Some(Commands::Server(_)) | Some(Commands::Web(_)) | Some(Commands::Match(_)) | Some(Commands::Ask(_)) | Some(Commands::Review(_)) | Some(Commands::ReviewsTodo(_)) | Some(Commands::Pr(_)) | Some(Commands::Gitignore(_)) | Some(Commands::Recipe(_)) | Some(Commands::GitRepair(_)) | Some(Commands::Jj(_)) | Some(Commands::Env(_)) | Some(Commands::Otp(_)) | Some(Commands::Auth(_)) | Some(Commands::Services(_)) | Some(Commands::Deps(_)) | Some(Commands::Db(_)) | Some(Commands::Home(_)) | Some(Commands::Hub(_)) | Some(Commands::AiTestNew(_)) | Some(Commands::Code(_)) | Some(Commands::Migrate(_)) | Some(Commands::Parallel(_)) | Some(Commands::Docs(_)) | Some(Commands::Upgrade(_)) | Some(Commands::Release(_)) | Some(Commands::Install(_)) | Some(Commands::Registry(_)) | Some(Commands::Domains(_)) | Some(Commands::Sync(_)) | Some(Commands::Checkout(_)) | Some(Commands::Switch(_)) | Some(Commands::Push(_)) | Some(Commands::Deploy(_)) | Some(Commands::Prod(_)) | Some(Commands::Publish(_)) | Some(Commands::Clone(_)) | Some(Commands::TaskShortcut(_)) | Some(Commands::Agents(_)) | Some(Commands::Hive(_)) => StartupPolicy::SECRETS_ONLY, Some(Commands::Undo(_)) => StartupPolicy::NONE, } } fn rerun(opts: RerunOpts) -> Result<()> { let project_root = if opts.config.is_absolute() { opts.config.parent().unwrap_or(Path::new(".")).to_path_buf() } else { std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf()) }; let record = history::load_last_record_for_project(&project_root)?; let Some(rec) = record else { bail!("no previous task found for this project"); }; // Parse user_input to extract task name and args (respecting shell quoting) let parts = shell_words::split(&rec.user_input).unwrap_or_else(|_| vec![rec.task_name.clone()]); let task_name = parts.first().cloned().unwrap_or(rec.task_name.clone()); let args: Vec<String> = parts.into_iter().skip(1).collect(); println!("Re-running: {}", rec.user_input); tasks::run(TaskRunOpts { config: opts.config, delegate_to_hub: false, hub_host: IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: task_name, args, }) } fn is_task_not_found(err: &anyhow::Error) -> bool { let msg = err.to_string().to_ascii_lowercase(); msg.contains("task '") && msg.contains("not found") } fn shell_command(cmd: ShellCommand) { match cmd.action.unwrap_or(ShellAction::Reset) { ShellAction::Reset => { shell_reset(); } ShellAction::FixTerminal => { shell_fix_terminal(); } } } fn shell_reset() { let home = dirs::home_dir().expect("no home directory"); let config_path = home.join("config").join("fish").join("config.fish"); if std::env::var("FISH_VERSION").is_ok() { println!("Run: source {}", config_path.display()); } else { println!( "Refresh your shell session (fish): source {}", config_path.display() ); } } fn shell_fix_terminal() { let status = std::process::Command::new("fish") .arg("-c") .arg("set -Ua fish_features no-query-term") .status(); match status { Ok(status) if status.success() => { println!("Disabled fish terminal query (no-query-term). Restart fish to apply."); } _ => { println!("Run in fish: set -Ua fish_features no-query-term"); println!("Then restart fish to apply."); } } } fn shell_init(shell: &str) { use std::fs; use std::io::Write; let home = dirs::home_dir().expect("no home directory"); let config_dir = home.join("config"); match shell { "fish" => { let config_fish = config_dir.join("fish").join("config.fish"); println!("No fish integration changes applied."); println!( "Manage your fish config manually: {}", config_fish.display() ); } "zsh" => { let zshrc = config_dir.join("zsh").join(".zshrc"); if zshrc.exists() { let content = fs::read_to_string(&zshrc).unwrap_or_default(); if content.contains("# flow:start") { println!("Already set up in {}", zshrc.display()); return; } } let snippet = r#" # flow:start f() { local bin if [[ -x ~/.local/bin/f ]]; then bin=~/.local/bin/f else bin=$(command -v f) fi case "$1" in new) local output output=$("$bin" "$@" 2>&1) echo "$output" local created created=$(echo "$output" | grep -oE 'Created .+' | cut -d' ' -f2-) if [[ -n "$created" && -d "$created" ]]; then cd "$created" fi ;; *) "$bin" "$@" ;; esac } # flow:end "#; let mut file = match fs::OpenOptions::new() .create(true) .append(true) .open(&zshrc) { Ok(f) => f, Err(e) => { eprintln!("Failed to open {}: {}", zshrc.display(), e); return; } }; if let Err(e) = file.write_all(snippet.as_bytes()) { eprintln!("Failed to write to {}: {}", zshrc.display(), e); return; } println!("Added flow integration to {}", zshrc.display()); } _ => { eprintln!("Unsupported shell: {}", shell); eprintln!("Supported: fish, zsh"); } } } /// Handle proxy commands fn proxy_command(cmd: ProxyCommand) -> Result<()> { // Helper to load config from current directory let load_project_config = || -> Result<flowd::config::Config> { let cwd = std::env::current_dir()?; let flow_toml = cwd.join("flow.toml"); if flow_toml.exists() { flowd::config::load(&flow_toml) } else { // Try global config let global = dirs::config_dir() .map(|d| d.join("flow").join("flow.toml")) .filter(|p| p.exists()); if let Some(path) = global { flowd::config::load(&path) } else { bail!("No flow.toml found in current directory or global config"); } } }; match cmd.action { ProxyAction::Start(opts) => { // Load config let config = load_project_config()?; let proxy_config = config.proxy.unwrap_or_default(); let targets = config.proxies; if targets.is_empty() { bail!("No proxy targets configured. Add [[proxies]] to flow.toml"); } // Override listen if provided let proxy_config = if let Some(listen) = opts.listen { proxy::ProxyConfig { listen, ..proxy_config } } else { proxy_config }; // Start server let rt = tokio::runtime::Runtime::new()?; rt.block_on(proxy::start(proxy_config, targets))?; } ProxyAction::Trace(opts) => { proxy::trace_last(opts.count)?; } ProxyAction::Last(_opts) => { proxy::trace_last(1)?; } ProxyAction::Add(opts) => { println!("To add a proxy, edit flow.toml:"); println!(); println!("[[proxies]]"); println!( "name = \"{}\"", opts.name.unwrap_or_else(|| "myservice".to_string()) ); println!("target = \"{}\"", opts.target); if let Some(host) = opts.host { println!("host = \"{}\"", host); } if let Some(path) = opts.path { println!("path = \"{}\"", path); } } ProxyAction::List => { let config = load_project_config()?; if config.proxies.is_empty() { println!("No proxy targets configured."); println!("Add [[proxies]] sections to flow.toml"); } else { println!( "{:<15} {:<25} {:<15} {:<15}", "NAME", "TARGET", "HOST", "PATH" ); println!("{}", "-".repeat(70)); for p in &config.proxies { println!( "{:<15} {:<25} {:<15} {:<15}", p.name, p.target, p.host.as_deref().unwrap_or("-"), p.path.as_deref().unwrap_or("-") ); } } } ProxyAction::Stop => { println!("Proxy stop not implemented yet. Use Ctrl+C or kill the process."); } } Ok(()) } #[cfg(test)] mod tests { use std::path::PathBuf; use super::{StartupPolicy, startup_policy_for}; use flowd::cli::{ AiAction, AiCommand, AnalyticsCommand, Commands, GlobalAction, GlobalCommand, RepoAliasAction, RepoAliasCommand, RepoCapsuleOpts, ReposAction, ReposCommand, SessionsOpts, StatusOpts, TasksAction, TasksBuildAiOpts, TasksCommand, TasksListOpts, UrlAction, UrlCommand, UrlCrawlOpts, UrlCrawlSource, UrlInspectOpts, UrlInspectProvider, }; #[test] fn startup_policy_skips_common_local_read_only_commands() { assert_eq!(startup_policy_for(None), StartupPolicy::NONE); assert_eq!( startup_policy_for(Some(&Commands::Status(StatusOpts::default()))), StartupPolicy::NONE ); assert_eq!( startup_policy_for(Some(&Commands::Tasks(TasksCommand { action: Some(TasksAction::List(TasksListOpts { config: PathBuf::from("flow.toml"), dupes: false, })), }))), StartupPolicy::NONE ); assert_eq!( startup_policy_for(Some(&Commands::Analytics(AnalyticsCommand { action: None, }))), StartupPolicy::NONE ); assert_eq!( startup_policy_for(Some(&Commands::Url(UrlCommand { action: UrlAction::Inspect(UrlInspectOpts { url: "https://example.com".to_string(), json: false, full: false, provider: UrlInspectProvider::Auto, timeout_s: 20.0, }), }))), StartupPolicy::SECRETS_ONLY ); assert_eq!( startup_policy_for(Some(&Commands::Url(UrlCommand { action: UrlAction::Crawl(UrlCrawlOpts { url: "https://developers.cloudflare.com".to_string(), json: false, full: false, limit: 10, depth: 2, records: 5, source: UrlCrawlSource::All, render: false, include_external_links: false, include_subdomains: false, include_patterns: Vec::new(), exclude_patterns: Vec::new(), max_age_s: None, wait_timeout_s: 60.0, poll_interval_s: 2.0, }), }))), StartupPolicy::SECRETS_ONLY ); } #[test] fn startup_policy_keeps_execution_paths_loading_secrets() { assert_eq!( startup_policy_for(Some(&Commands::Tasks(TasksCommand { action: Some(TasksAction::BuildAi(TasksBuildAiOpts { name: "ai:flow/noop".to_string(), root: PathBuf::from("."), force: false, })), }))), StartupPolicy::SECRETS_ONLY ); assert_eq!( startup_policy_for(Some(&Commands::Global(GlobalCommand { action: Some(GlobalAction::Run { task: "setup".to_string(), args: vec![], }), task: None, list: false, args: vec![], }))), StartupPolicy::SECRETS_ONLY ); } #[test] fn startup_policy_syncs_skills_for_ai_heavy_paths() { assert_eq!( startup_policy_for(Some(&Commands::Ai(AiCommand { action: Some(AiAction::List), }))), StartupPolicy::FULL ); assert_eq!( startup_policy_for(Some(&Commands::Sessions(SessionsOpts { provider: "all".to_string(), count: None, list: false, full: false, summarize: true, handoff: false, }))), StartupPolicy::SECRETS_ONLY ); } #[test] fn startup_policy_keeps_repos_capsule_on_fast_path() { assert_eq!( startup_policy_for(Some(&Commands::Repos(ReposCommand { action: Some(ReposAction::Capsule(RepoCapsuleOpts { path: None, refresh: false, json: false, })), }))), StartupPolicy::NONE ); } #[test] fn startup_policy_keeps_repos_alias_on_fast_path() { assert_eq!( startup_policy_for(Some(&Commands::Repos(ReposCommand { action: Some(ReposAction::Alias(RepoAliasCommand { action: Some(RepoAliasAction::List { json: false }), })), }))), StartupPolicy::NONE ); } } ================================================ FILE: src/notify.rs ================================================ //! Notify command - sends proposals and alerts to Lin app. use crate::cli::NotifyCommand; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; /// Proposal format matching Lin's ProposalService.swift #[derive(Debug, Serialize, Deserialize)] struct Proposal { id: String, timestamp: i64, title: String, action: String, context: Option<String>, #[serde(rename = "expires_at")] expires_at: i64, } /// Alert format for Lin's NotificationBannerManager. #[derive(Debug, Clone, Serialize, Deserialize)] struct Alert { id: String, timestamp: i64, text: String, kind: String, // "info", "warning", "error", "success" #[serde(rename = "expires_at")] expires_at: i64, } /// Get the path to Lin's proposals.json file. fn get_proposals_path() -> Result<PathBuf> { let home = dirs::home_dir().context("Could not find home directory")?; let path = home .join("Library") .join("Application Support") .join("Lin") .join("proposals.json"); Ok(path) } /// Run the notify command - write a proposal to Lin's proposals.json. pub fn run(cmd: NotifyCommand) -> Result<()> { let proposals_path = get_proposals_path()?; // Ensure the directory exists if let Some(parent) = proposals_path.parent() { fs::create_dir_all(parent)?; } // Read existing proposals let mut proposals: Vec<Proposal> = if proposals_path.exists() { let content = fs::read_to_string(&proposals_path)?; serde_json::from_str(&content).unwrap_or_default() } else { Vec::new() }; // Get current timestamp let now = SystemTime::now() .duration_since(UNIX_EPOCH) .context("Time went backwards")? .as_secs() as i64; // Create title from action if not provided let title = cmd.title.unwrap_or_else(|| { // Extract a nice title from the action let action = &cmd.action; if action.starts_with("f ") { format!("Run: {}", &action[2..]) } else { format!("Run: {}", action) } }); // Create new proposal let proposal = Proposal { id: Uuid::new_v4().to_string(), timestamp: now, title, action: cmd.action.clone(), context: cmd.context, expires_at: now + cmd.expires as i64, }; // Add to proposals proposals.push(proposal); // Write back let content = serde_json::to_string_pretty(&proposals)?; fs::write(&proposals_path, content)?; println!("Proposal sent to Lin: {}", cmd.action); Ok(()) } // ============================================================================ // Alerts API (for commit rejections, errors, etc.) // ============================================================================ /// Get the path to Lin's alerts.json file. fn get_alerts_path() -> Result<PathBuf> { let home = dirs::home_dir().context("Could not find home directory")?; let path = home .join("Library") .join("Application Support") .join("Lin") .join("alerts.json"); Ok(path) } /// Alert kind for Lin's NotificationBannerManager. #[derive(Debug, Clone, Copy)] pub enum AlertKind { Info, Warning, Error, Success, } impl AlertKind { fn as_str(&self) -> &'static str { match self { AlertKind::Info => "info", AlertKind::Warning => "warning", AlertKind::Error => "error", AlertKind::Success => "success", } } } /// Send an alert to Lin's notification banner. /// Alerts are shown as floating banners - errors/warnings stay for 10+ seconds. pub fn send_alert(text: &str, kind: AlertKind) -> Result<()> { let alerts_path = get_alerts_path()?; // Ensure the directory exists if let Some(parent) = alerts_path.parent() { fs::create_dir_all(parent)?; } // Read existing alerts let mut alerts: Vec<Alert> = if alerts_path.exists() { let content = fs::read_to_string(&alerts_path)?; serde_json::from_str(&content).unwrap_or_default() } else { Vec::new() }; // Get current timestamp let now = SystemTime::now() .duration_since(UNIX_EPOCH) .context("Time went backwards")? .as_secs() as i64; // Determine expiry based on kind (warnings/errors stay longer) let duration = match kind { AlertKind::Error | AlertKind::Warning => 30, // 30 seconds for errors/warnings AlertKind::Success => 5, AlertKind::Info => 10, }; // Create new alert let alert = Alert { id: Uuid::new_v4().to_string(), timestamp: now, text: text.to_string(), kind: kind.as_str().to_string(), expires_at: now + duration, }; // Add to alerts alerts.push(alert); // Clean up old alerts (keep last 20) if alerts.len() > 20 { let skip_count = alerts.len() - 20; alerts = alerts.into_iter().skip(skip_count).collect(); } // Write back let content = serde_json::to_string_pretty(&alerts)?; fs::write(&alerts_path, content)?; Ok(()) } /// Send an error alert to Lin. pub fn send_error(text: &str) -> Result<()> { send_alert(text, AlertKind::Error) } /// Send a warning alert to Lin. pub fn send_warning(text: &str) -> Result<()> { send_alert(text, AlertKind::Warning) } ================================================ FILE: src/opentui_prompt.rs ================================================ use std::io::{self, IsTerminal}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use opentui_lite::{ATTR_BOLD, BORDER_SIMPLE, Color, OpenTui}; pub fn confirm(title: &str, lines: &[String], default_yes: bool) -> Option<bool> { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { return None; } let (width, height) = crossterm::terminal::size().ok()?; let opentui = OpenTui::load().ok()?; let renderer = opentui .create_renderer(width as u32, height as u32, false) .ok()?; renderer.setup_terminal(true); let _raw = RawModeGuard::new().ok()?; let bg = Color::rgb(0.06, 0.07, 0.09); let border = Color::rgb(0.32, 0.42, 0.62); let text = Color::rgb(0.92, 0.94, 0.96); let muted = Color::rgb(0.68, 0.72, 0.78); let accent = Color::rgb(0.90, 0.76, 0.34); let buffer = renderer.next_buffer(); buffer.clear(bg); let packed_options = 0b1_1111u32; buffer.draw_box( 0, 0, width as u32, height as u32, &BORDER_SIMPLE, packed_options, border, bg, Some(title), ); let max_width = width.saturating_sub(4) as usize; let mut y = 2u32; let title_line = truncate_width(title, max_width); buffer.draw_text(&title_line, 3, y, text, None, ATTR_BOLD); y += 2; for line in lines { if y >= height.saturating_sub(3) as u32 { break; } let line = truncate_width(line, max_width); buffer.draw_text(&line, 3, y, text, None, 0); y += 1; } let hint = if default_yes { "Enter/Y = yes, N/Esc = no" } else { "Enter/N = no, Y = yes" }; let hint_line = truncate_width(hint, max_width); let hint_y = height.saturating_sub(2) as u32; buffer.draw_text(&hint_line, 3, hint_y, muted, None, 0); let action = if default_yes { "[Y] Confirm" } else { "[N] Cancel" }; let action_line = truncate_width(action, max_width); buffer.draw_text( &action_line, 3, hint_y.saturating_sub(1), accent, None, ATTR_BOLD, ); renderer.render(true); let answer = loop { match event::read() { Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code { KeyCode::Enter => break default_yes, KeyCode::Char('y') | KeyCode::Char('Y') => break true, KeyCode::Char('n') | KeyCode::Char('N') => break false, KeyCode::Esc => break false, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break false, _ => {} }, Ok(_) => {} Err(_) => break default_yes, } }; renderer.clear_terminal(); renderer.suspend(); Some(answer) } struct RawModeGuard; impl RawModeGuard { fn new() -> std::io::Result<Self> { enable_raw_mode()?; Ok(Self) } } impl Drop for RawModeGuard { fn drop(&mut self) { let _ = disable_raw_mode(); } } fn truncate_width(input: &str, max: usize) -> String { if input.len() <= max { return input.to_string(); } input.chars().take(max).collect::<String>() } ================================================ FILE: src/otp.rs ================================================ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use data_encoding::BASE32_NOPAD; use hmac::{Hmac, Mac}; use reqwest::blocking::Client; use serde::Deserialize; use sha1::Sha1; use sha2::{Sha256, Sha512}; use url::Url; use crate::cli::{OtpAction, OtpCommand}; use crate::env; #[derive(Debug, Clone)] struct ConnectConfig { host: String, token: String, } #[derive(Debug, Deserialize)] struct Vault { id: String, name: String, } #[derive(Debug, Deserialize)] struct ItemSummary { id: String, title: String, } #[derive(Debug, Deserialize)] struct Field { #[serde(rename = "type")] field_type: String, label: Option<String>, value: Option<String>, } #[derive(Debug, Deserialize)] struct FullItem { #[allow(dead_code)] id: String, title: String, fields: Option<Vec<Field>>, } pub fn run(cmd: OtpCommand) -> Result<()> { match cmd.action { OtpAction::Get { vault, item, field } => { let config = load_connect_config()?; let code = fetch_totp(&config, &vault, &item, field.as_deref())?; println!("{code}"); } } Ok(()) } fn load_connect_config() -> Result<ConnectConfig> { let host = std::env::var("OP_CONNECT_HOST") .or_else(|_| std::env::var("OP_CONNECT_URL")) .map_err(|_| anyhow::anyhow!("OP_CONNECT_HOST is not set"))?; let token = std::env::var("OP_CONNECT_TOKEN").ok().or_else(|| { env::fetch_personal_env_vars(&["OP_CONNECT_TOKEN".to_string()]) .ok() .and_then(|vars| vars.get("OP_CONNECT_TOKEN").cloned()) }); let Some(token) = token else { bail!("OP_CONNECT_TOKEN not found in env or Flow env store"); }; Ok(ConnectConfig { host, token }) } fn fetch_totp( config: &ConnectConfig, vault_ref: &str, item_ref: &str, field_label: Option<&str>, ) -> Result<String> { let client = Client::builder() .build() .context("failed to build HTTP client")?; let vault_id = resolve_vault_id(&client, config, vault_ref)?; let item_id = resolve_item_id(&client, config, &vault_id, item_ref)?; let item = fetch_item(&client, config, &vault_id, &item_id)?; let totp_uri = extract_totp_uri(&item, field_label)?; compute_totp(&totp_uri) } fn resolve_vault_id(client: &Client, config: &ConnectConfig, vault_ref: &str) -> Result<String> { let url = format!("{}/v1/vaults", config.host.trim_end_matches('/')); let vaults: Vec<Vault> = client .get(url) .bearer_auth(&config.token) .send() .context("failed to list 1Password vaults")? .error_for_status() .context("1Password connect returned an error for vault list")? .json() .context("failed to parse vault list")?; if let Some(vault) = vaults .iter() .find(|v| v.id == vault_ref || v.name == vault_ref) { return Ok(vault.id.clone()); } bail!("vault not found: {}", vault_ref); } fn resolve_item_id( client: &Client, config: &ConnectConfig, vault_id: &str, item_ref: &str, ) -> Result<String> { let url = format!( "{}/v1/vaults/{}/items", config.host.trim_end_matches('/'), vault_id ); let items: Vec<ItemSummary> = client .get(url) .bearer_auth(&config.token) .send() .context("failed to list 1Password items")? .error_for_status() .context("1Password connect returned an error for item list")? .json() .context("failed to parse item list")?; if let Some(item) = items .iter() .find(|i| i.id == item_ref || i.title == item_ref) { return Ok(item.id.clone()); } bail!("item not found: {}", item_ref); } fn fetch_item( client: &Client, config: &ConnectConfig, vault_id: &str, item_id: &str, ) -> Result<FullItem> { let url = format!( "{}/v1/vaults/{}/items/{}", config.host.trim_end_matches('/'), vault_id, item_id ); let item: FullItem = client .get(url) .bearer_auth(&config.token) .send() .context("failed to fetch 1Password item")? .error_for_status() .context("1Password connect returned an error for item fetch")? .json() .context("failed to parse item")?; Ok(item) } fn extract_totp_uri(item: &FullItem, field_label: Option<&str>) -> Result<String> { let fields = item.fields.as_ref().ok_or_else(|| { anyhow::anyhow!("item '{}' has no fields; expected a TOTP field", item.title) })?; let mut candidates: Vec<&Field> = fields .iter() .filter(|field| field.field_type.eq_ignore_ascii_case("TOTP")) .collect(); if let Some(label) = field_label { let label_lower = label.to_lowercase(); candidates = candidates .into_iter() .filter(|field| { field .label .as_ref() .map(|l| l.to_lowercase() == label_lower) .unwrap_or(false) }) .collect(); } let field = candidates .first() .ok_or_else(|| anyhow::anyhow!("no TOTP field found in item '{}'", item.title))?; let value = field .value .as_ref() .ok_or_else(|| anyhow::anyhow!("TOTP field in '{}' has no value", item.title))?; Ok(value.clone()) } fn compute_totp(uri: &str) -> Result<String> { if !uri.starts_with("otpauth://") { return compute_totp_from_secret(uri, 30, 6, "SHA1"); } let url = Url::parse(uri).context("failed to parse otpauth URI")?; if url.scheme() != "otpauth" { bail!("unsupported OTP URI scheme: {}", url.scheme()); } let mut secret: Option<String> = None; let mut digits: u32 = 6; let mut period: u64 = 30; let mut algorithm = "SHA1".to_string(); for (key, value) in url.query_pairs() { match key.as_ref() { "secret" => secret = Some(value.to_string()), "digits" => digits = value.parse::<u32>().unwrap_or(6), "period" => period = value.parse::<u64>().unwrap_or(30), "algorithm" => algorithm = value.to_string(), _ => {} } } let secret = secret.ok_or_else(|| anyhow::anyhow!("otpauth URI missing secret"))?; compute_totp_from_secret(&secret, period, digits, &algorithm) } fn compute_totp_from_secret( secret: &str, period: u64, digits: u32, algorithm: &str, ) -> Result<String> { let key = decode_base32(secret)?; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .context("system clock before Unix epoch")? .as_secs(); let counter = timestamp / period; let msg = counter.to_be_bytes(); let algo_upper = algorithm.to_uppercase(); let hash = if algo_upper == "SHA256" { hmac_sha256(&key, &msg) } else if algo_upper == "SHA512" { hmac_sha512(&key, &msg) } else { hmac_sha1(&key, &msg) }; let offset = (hash[hash.len() - 1] & 0x0f) as usize; let slice = &hash[offset..offset + 4]; let mut code = ((u32::from(slice[0]) & 0x7f) << 24) | (u32::from(slice[1]) << 16) | (u32::from(slice[2]) << 8) | u32::from(slice[3]); let modulo = 10u32.pow(digits); code %= modulo; Ok(format!("{:0width$}", code, width = digits as usize)) } fn decode_base32(secret: &str) -> Result<Vec<u8>> { let normalized = secret.trim().replace(' ', "").to_uppercase(); BASE32_NOPAD .decode(normalized.as_bytes()) .context("failed to decode base32 secret") } fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec<u8> { let mut mac = Hmac::<Sha1>::new_from_slice(key).expect("HMAC accepts any key size"); mac.update(msg); mac.finalize().into_bytes().to_vec() } fn hmac_sha256(key: &[u8], msg: &[u8]) -> Vec<u8> { let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key size"); mac.update(msg); mac.finalize().into_bytes().to_vec() } fn hmac_sha512(key: &[u8], msg: &[u8]) -> Vec<u8> { let mut mac = Hmac::<Sha512>::new_from_slice(key).expect("HMAC accepts any key size"); mac.update(msg); mac.finalize().into_bytes().to_vec() } ================================================ FILE: src/palette.rs ================================================ use std::{ io::Write, path::PathBuf, process::{Command, Stdio}, }; use anyhow::{Context, Result, bail}; use crate::{ ai_tasks, cli::TasksOpts, config::{self, TaskConfig}, discover::DiscoveredTask, project_snapshot::ProjectSnapshot, }; pub fn run(opts: TasksOpts) -> Result<()> { let entries = build_entries(Some(opts))?; present(entries) } /// Show global commands/tasks only (no project flow.toml required). pub fn run_global() -> Result<()> { let entries = build_entries(None)?; present(entries) } struct FzfResult<'a> { entry: &'a PaletteEntry, with_args: bool, } fn run_fzf<'a>(entries: &'a [PaletteEntry]) -> Result<Option<FzfResult<'a>>> { let mut child = Command::new("fzf") .arg("--prompt") .arg("f> ") .arg("--expect") .arg("tab") // tab to run with args prompt .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for entry in entries { writeln!(stdin, "{}", entry.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let raw = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let mut lines = raw.lines(); // First line is the key pressed (if any from --expect) let key = lines.next().unwrap_or(""); let with_args = key == "tab"; // Second line is the selection let selection = lines.next().unwrap_or("").trim(); if selection.is_empty() { return Ok(None); } let entry = entries.iter().find(|entry| entry.display == selection); Ok(entry.map(|e| FzfResult { entry: e, with_args, })) } fn run_entry(entry: &PaletteEntry, extra_args: Vec<String>) -> Result<()> { let exe = std::env::current_exe().context("failed to resolve current executable")?; let status = Command::new(exe) .args(&entry.exec) .args(&extra_args) .status() .with_context(|| format!("failed to run {}", entry.display))?; if status.success() { Ok(()) } else { bail!( "{} exited with status {}", entry.display, status.code().unwrap_or(-1) ); } } fn present(entries: Vec<PaletteEntry>) -> Result<()> { if entries.is_empty() { println!("No commands or tasks available. Add entries to flow.toml or global config."); return Ok(()); } if which::which("fzf").is_err() { println!("fzf not found on PATH – install it to use fuzzy selection."); println!("Available commands:"); for entry in &entries { println!(" {}", entry.display); } return Ok(()); } if let Some(result) = run_fzf(&entries)? { let extra_args = if result.with_args { prompt_for_args(&result.entry.display)? } else { Vec::new() }; run_entry(result.entry, extra_args)?; } Ok(()) } fn prompt_for_args(task_display: &str) -> Result<Vec<String>> { use std::io::{self, BufRead}; // Extract task name from display (e.g., "[task] foo – description" -> "foo") let task_name = task_display .strip_prefix("[task] ") .and_then(|s| s.split(" – ").next()) .and_then(|s| s.split(" (").next()) // handle "(path)" suffix .unwrap_or("task"); // Show hint about quoting for args with spaces println!("(tip: use quotes for args with spaces, e.g. 'my prompt')"); print!("f {} ", task_name); io::stdout().flush()?; let stdin = io::stdin(); let line = stdin.lock().lines().next(); let input = match line { Some(Ok(s)) => s, _ => return Ok(Vec::new()), }; let args = shell_words::split(&input).context("failed to parse arguments")?; Ok(args) } struct PaletteEntry { display: String, exec: Vec<String>, } impl PaletteEntry { fn new(display: &str, exec: Vec<String>) -> Self { Self { display: display.to_string(), exec, } } fn from_task(task: &TaskConfig, config_arg: &str) -> Self { let summary = task .description .as_deref() .unwrap_or_else(|| task.command.as_str()); let display = format!("[task] {} – {}", task.name, truncate(summary, 96)); let exec = vec![ "run".into(), "--config".into(), config_arg.to_string(), task.name.clone(), ]; Self { display, exec } } fn from_discovered(discovered: &DiscoveredTask) -> Self { let summary = discovered .task .description .as_deref() .unwrap_or_else(|| discovered.task.command.as_str()); let display = if let Some(path_label) = discovered.path_label() { format!( "[task] {} ({}) – {}", discovered.task.name, path_label, truncate(summary, 80) ) } else { format!( "[task] {} – {}", discovered.task.name, truncate(summary, 96) ) }; let exec = vec![ "run".into(), "--config".into(), discovered.config_path.display().to_string(), discovered.task.name.clone(), ]; Self { display, exec } } fn from_ai_task(task: &ai_tasks::DiscoveredAiTask) -> Self { let summary = if task.description.trim().is_empty() { format!("moon run {}", task.path.display()) } else { task.description.trim().to_string() }; let display = format!("[task] {} – {}", task.id, truncate(&summary, 96)); let exec = vec![task.id.clone()]; Self { display, exec } } } fn build_entries(project_opts: Option<TasksOpts>) -> Result<Vec<PaletteEntry>> { let mut entries = Vec::new(); let global_cfg = load_if_exists(config::default_config_path())?; let mut has_project = false; if let Some(opts) = project_opts { let snapshot = ProjectSnapshot::from_task_config(&opts.config, false)?; if snapshot.has_any_tasks() { has_project = true; for discovered in &snapshot.discovery.tasks { entries.push(PaletteEntry::from_discovered(discovered)); } for task in &snapshot.ai_tasks { entries.push(PaletteEntry::from_ai_task(task)); } } } if has_project { return Ok(entries); } entries.extend(builtin_entries()); if let Some((global_path, cfg)) = global_cfg { let arg = global_path.display().to_string(); for task in &cfg.tasks { entries.push(PaletteEntry::from_task(task, &arg)); } } Ok(entries) } fn builtin_entries() -> Vec<PaletteEntry> { let entries = vec![ PaletteEntry::new("[cmd] hub – ensure daemon is running", vec!["hub".into()]), PaletteEntry::new( "[cmd] search – global commands/tasks", vec!["search".into()], ), PaletteEntry::new("[cmd] init – scaffold flow.toml", vec!["init".into()]), ]; entries } fn load_if_exists(path: PathBuf) -> Result<Option<(PathBuf, config::Config)>> { if path.exists() { let cfg = config::load(&path)?; Ok(Some((path, cfg))) } else { Ok(None) } } fn truncate(input: &str, max: usize) -> String { let mut out = String::new(); for ch in input.chars() { if out.chars().count() + 1 >= max { break; } out.push(ch); } if out.len() < input.len() { out.push('…'); } out } ================================================ FILE: src/parallel.rs ================================================ //! Parallel task runner with pretty status display. use std::io::{self, Write}; use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use anyhow::{Result, bail}; use crossterm::terminal; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use tokio::sync::{Mutex, Semaphore}; // ANSI escape codes const RESET: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; const DIM: &str = "\x1b[2m"; const RED: &str = "\x1b[31m"; const GREEN: &str = "\x1b[32m"; const BLUE: &str = "\x1b[34m"; const MAGENTA: &str = "\x1b[35m"; const CYAN: &str = "\x1b[36m"; const CLEAR_LINE: &str = "\x1b[2K"; const HIDE_CURSOR: &str = "\x1b[?25l"; const SHOW_CURSOR: &str = "\x1b[?25h"; // Spinner frames const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER_COLORS: &[&str] = &[CYAN, BLUE, MAGENTA, BLUE]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TaskStatus { Pending, Running, Success, Failure, Skipped, } #[derive(Debug, Clone)] pub struct Task { pub label: String, pub command: String, pub status: TaskStatus, pub last_line: String, pub exit_code: Option<i32>, pub output: Vec<String>, pub duration: Option<Duration>, } impl Task { pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self { Self { label: label.into(), command: command.into(), status: TaskStatus::Pending, last_line: String::new(), exit_code: None, output: Vec::new(), duration: None, } } } pub struct ParallelRunner { tasks: Arc<Mutex<Vec<Task>>>, max_jobs: usize, fail_fast: bool, spinner_index: AtomicUsize, lines_printed: AtomicUsize, should_stop: AtomicBool, first_failure_code: Arc<Mutex<Option<i32>>>, } impl ParallelRunner { pub fn new(tasks: Vec<Task>, max_jobs: usize, fail_fast: bool) -> Self { Self { tasks: Arc::new(Mutex::new(tasks)), max_jobs, fail_fast, spinner_index: AtomicUsize::new(0), lines_printed: AtomicUsize::new(0), should_stop: AtomicBool::new(false), first_failure_code: Arc::new(Mutex::new(None)), } } fn get_spinner(&self) -> String { let idx = self.spinner_index.load(Ordering::Relaxed); let frame = SPINNER_FRAMES[idx % SPINNER_FRAMES.len()]; let color = SPINNER_COLORS[idx % SPINNER_COLORS.len()]; format!("{}{}{}", color, frame, RESET) } fn terminal_width() -> usize { terminal::size().map(|(w, _)| w as usize).unwrap_or(80) } fn truncate_line(text: &str, max_width: usize) -> String { if text.len() <= max_width { return text.to_string(); } if max_width <= 1 { return text.chars().take(max_width).collect(); } format!("{}…", &text[..max_width - 1]) } fn strip_ansi(text: &str) -> String { let mut result = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); while let Some(c) = chars.next() { if c == '\x1b' { // Skip escape sequence if chars.peek() == Some(&'[') { chars.next(); while let Some(&next) = chars.peek() { chars.next(); if next.is_ascii_alphabetic() { break; } } } } else { result.push(c); } } result } fn format_task_line(&self, task: &Task, label_width: usize) -> String { let term_width = Self::terminal_width(); let icon = match task.status { TaskStatus::Pending => format!("{}○{}", DIM, RESET), TaskStatus::Running => self.get_spinner(), TaskStatus::Success => format!("{}✓{}", GREEN, RESET), TaskStatus::Failure => format!("{}✗{}", RED, RESET), TaskStatus::Skipped => format!("{}○{}", DIM, RESET), }; let label = format!("{:width$}", task.label, width = label_width); let prefix = format!("{} {}{}{}", icon, BOLD, label, RESET); let prefix_len = 1 + 1 + label_width; match task.status { TaskStatus::Success => { if let Some(dur) = task.duration { format!("{} {}({:.1}s){}", prefix, DIM, dur.as_secs_f64(), RESET) } else { prefix } } TaskStatus::Failure => { format!( "{} {}(exit {}){}", prefix, DIM, task.exit_code.unwrap_or(-1), RESET ) } TaskStatus::Skipped => { format!("{} {}(skipped){}", prefix, DIM, RESET) } TaskStatus::Pending => prefix, TaskStatus::Running => { if !task.last_line.is_empty() { let clean = Self::strip_ansi(&task.last_line) .chars() .filter(|c| c.is_ascii_graphic() || *c == ' ') .collect::<String>(); let available = term_width.saturating_sub(prefix_len + 3); if available > 0 { let truncated = Self::truncate_line(&clean, available); format!("{} {}{}{}", prefix, DIM, truncated, RESET) } else { prefix } } else { prefix } } } } async fn render_display(&self) { let tasks = self.tasks.lock().await; let lines_printed = self.lines_printed.load(Ordering::Relaxed); // Move cursor up if lines_printed > 0 { print!("\x1b[{}A", lines_printed); } let label_width = tasks.iter().map(|t| t.label.len()).max().unwrap_or(0); for task in tasks.iter() { let line = self.format_task_line(task, label_width); println!("{}{}", CLEAR_LINE, line); } self.lines_printed.store(tasks.len(), Ordering::Relaxed); let _ = io::stdout().flush(); } async fn run_task(&self, task_idx: usize, semaphore: Arc<Semaphore>) { let _permit = semaphore.acquire().await.unwrap(); if self.should_stop.load(Ordering::Relaxed) { let mut tasks = self.tasks.lock().await; tasks[task_idx].status = TaskStatus::Skipped; return; } let command = { let mut tasks = self.tasks.lock().await; tasks[task_idx].status = TaskStatus::Running; tasks[task_idx].command.clone() }; let start = Instant::now(); let mut child = match Command::new("sh") .arg("-c") .arg(&command) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() { Ok(c) => c, Err(e) => { let mut tasks = self.tasks.lock().await; tasks[task_idx].status = TaskStatus::Failure; tasks[task_idx].exit_code = Some(-1); tasks[task_idx] .output .push(format!("Failed to spawn: {}", e)); tasks[task_idx].duration = Some(start.elapsed()); if self.fail_fast { self.should_stop.store(true, Ordering::Relaxed); let mut first = self.first_failure_code.lock().await; if first.is_none() { *first = Some(-1); } } return; } }; // Read stdout and stderr let stdout = child.stdout.take(); let stderr = child.stderr.take(); let tasks_clone = Arc::clone(&self.tasks); let idx = task_idx; let stdout_handle = if let Some(stdout) = stdout { let tasks = Arc::clone(&tasks_clone); Some(tokio::spawn(async move { let mut reader = BufReader::new(stdout).lines(); while let Ok(Some(line)) = reader.next_line().await { let mut tasks = tasks.lock().await; tasks[idx].output.push(format!("{}\n", line)); tasks[idx].last_line = line; } })) } else { None }; let stderr_handle = if let Some(stderr) = stderr { let tasks = Arc::clone(&tasks_clone); Some(tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { let mut tasks = tasks.lock().await; tasks[idx].output.push(format!("{}\n", line)); if tasks[idx].last_line.is_empty() { tasks[idx].last_line = line; } } })) } else { None }; // Wait for process let status = child.wait().await; let duration = start.elapsed(); // Wait for output readers if let Some(h) = stdout_handle { let _ = h.await; } if let Some(h) = stderr_handle { let _ = h.await; } let exit_code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1); { let mut tasks = self.tasks.lock().await; tasks[task_idx].exit_code = Some(exit_code); tasks[task_idx].duration = Some(duration); if exit_code == 0 { tasks[task_idx].status = TaskStatus::Success; } else { tasks[task_idx].status = TaskStatus::Failure; if self.fail_fast { self.should_stop.store(true, Ordering::Relaxed); } let mut first = self.first_failure_code.lock().await; if first.is_none() { *first = Some(exit_code); } } } } pub async fn run(self: Arc<Self>) -> i32 { // Hide cursor print!("{}", HIDE_CURSOR); let _ = io::stdout().flush(); let semaphore = Arc::new(Semaphore::new(self.max_jobs)); let task_count = self.tasks.lock().await.len(); // Spawn all tasks let mut handles = Vec::new(); for i in 0..task_count { let sem = Arc::clone(&semaphore); let runner = Arc::clone(&self); handles.push(tokio::spawn(async move { runner.run_task(i, sem).await; })); } // Spinner loop let spinner_handle = { let runner = Arc::clone(&self); tokio::spawn(async move { loop { if runner.should_stop.load(Ordering::Relaxed) { let tasks = runner.tasks.lock().await; if tasks.iter().all(|t| { matches!( t.status, TaskStatus::Success | TaskStatus::Failure | TaskStatus::Skipped ) }) { break; } } runner.spinner_index.fetch_add(1, Ordering::Relaxed); runner.render_display().await; tokio::time::sleep(Duration::from_millis(80)).await; let tasks = runner.tasks.lock().await; if tasks.iter().all(|t| { matches!( t.status, TaskStatus::Success | TaskStatus::Failure | TaskStatus::Skipped ) }) { break; } } }) }; // Wait for all tasks for h in handles { let _ = h.await; } self.should_stop.store(true, Ordering::Relaxed); let _ = spinner_handle.await; // Final render self.render_display().await; // Print failures let tasks = self.tasks.lock().await; let failed: Vec<_> = tasks .iter() .filter(|t| t.status == TaskStatus::Failure) .collect(); if !failed.is_empty() { println!(); for task in failed { println!( "{}{}━━━ {} (exit {}) ━━━{}", RED, BOLD, task.label, task.exit_code.unwrap_or(-1), RESET ); let output = task.output.join(""); if !output.trim().is_empty() { print!("{}", output); } println!(); } } // Show cursor print!("{}", SHOW_CURSOR); let _ = io::stdout().flush(); self.first_failure_code.lock().await.unwrap_or(0) } } /// Run tasks in parallel with pretty output. pub async fn run_parallel( tasks: Vec<(&str, &str)>, max_jobs: usize, fail_fast: bool, ) -> Result<()> { if tasks.is_empty() { bail!("No tasks specified"); } let tasks: Vec<Task> = tasks .into_iter() .map(|(label, cmd)| Task::new(label, cmd)) .collect(); let runner = Arc::new(ParallelRunner::new(tasks, max_jobs, fail_fast)); let exit_code = runner.run().await; if exit_code != 0 { std::process::exit(exit_code); } Ok(()) } /// CLI entry point for `f parallel`. pub fn run(cmd: crate::cli::ParallelCommand) -> Result<()> { use tokio::runtime::Runtime; if cmd.tasks.is_empty() { bail!("No tasks specified. Usage: f parallel 'echo hello' 'echo world' or 'label:command'"); } // Parse tasks: either "label:command" or just "command" (auto-labeled) let tasks: Vec<(String, String)> = cmd .tasks .iter() .enumerate() .map(|(i, t)| { if let Some((label, command)) = t.split_once(':') { (label.to_string(), command.to_string()) } else { // Auto-generate label from command or use index let label = t .split_whitespace() .next() .unwrap_or(&format!("task{}", i + 1)) .to_string(); (label, t.to_string()) } }) .collect(); let max_jobs = cmd.jobs.unwrap_or_else(|| { std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4) }); let rt = Runtime::new()?; rt.block_on(async { let task_refs: Vec<(&str, &str)> = tasks .iter() .map(|(l, c)| (l.as_str(), c.as_str())) .collect(); run_parallel(task_refs, max_jobs, cmd.fail_fast).await }) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_parallel_success() { let tasks = vec![ Task::new("echo1", "echo hello"), Task::new("echo2", "echo world"), ]; let runner = Arc::new(ParallelRunner::new(tasks, 4, false)); let code = runner.run().await; assert_eq!(code, 0); } #[tokio::test] async fn test_parallel_failure() { let tasks = vec![Task::new("fail", "exit 1"), Task::new("pass", "echo ok")]; let runner = Arc::new(ParallelRunner::new(tasks, 4, false)); let code = runner.run().await; assert_eq!(code, 1); } } ================================================ FILE: src/path_hygiene.rs ================================================ #[cfg(test)] mod tests { use std::fs; use std::path::{Path, PathBuf}; fn scan_dir(root: &Path, hits: &mut Vec<String>) { let entries = fs::read_dir(root).unwrap_or_else(|err| { panic!("failed to read {}: {err}", root.display()); }); for entry in entries { let entry = entry.expect("read_dir entry"); let path = entry.path(); if path.is_dir() { scan_dir(&path, hits); continue; } scan_file(&path, hits); } } fn scan_file(path: &Path, hits: &mut Vec<String>) { let Ok(contents) = fs::read_to_string(path) else { return; }; let prefix = format!("/{}/", "Users"); let banned = [ format!("{prefix}{}", "nikiv"), format!("{prefix}{}", "nikitavoloboev"), ]; for (line_no, line) in contents.lines().enumerate() { if banned.iter().any(|needle| line.contains(needle)) { hits.push(format!("{}:{}", path.display(), line_no + 1)); } } } #[test] fn repo_avoids_committed_absolute_user_home_paths() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let mut hits = Vec::new(); for rel in [ "src", "docs", ".ai/skills", "flow.toml", "readme.md", "install.sh", ] { let path = root.join(rel); if path.is_dir() { scan_dir(&path, &mut hits); } else { scan_file(&path, &mut hits); } } assert!( hits.is_empty(), "use ~/ instead of absolute home paths in committed files:\n{}", hits.join("\n") ); } } ================================================ FILE: src/pr_edit.rs ================================================ use std::{ collections::HashMap, path::{Path, PathBuf}, process::Command, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result, bail}; use notify::RecursiveMode; use notify_debouncer_mini::new_debouncer; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::sync::{RwLock, mpsc}; const STATUS_FILENAME: &str = "status.json"; const INDEX_FILENAME: &str = ".index.json"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrMeta { pub repo: String, // "owner/repo" pub pr: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SyncState { Clean, Dirty, Syncing, Error, Unknown, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileStatus { pub path: String, #[serde(default)] pub meta: Option<PrMeta>, pub state: SyncState, #[serde(default)] pub last_synced_at_ms: Option<i64>, #[serde(default)] pub last_error: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct StatusSnapshot { pub updated_at_ms: i64, pub files: Vec<FileStatus>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct IndexFile { version: u32, files: HashMap<String, PrMeta>, } #[derive(Clone)] pub struct PrEditService { dir: PathBuf, statuses: Arc<RwLock<HashMap<PathBuf, FileStatusInternal>>>, index: Arc<RwLock<IndexFile>>, gh_token: Arc<RwLock<Option<String>>>, client: reqwest::Client, } #[derive(Debug, Clone)] struct FileStatusInternal { public: FileStatus, last_digest_hex: Option<String>, // sha256(title + "\n" + body) } impl PrEditService { pub async fn start() -> Result<Arc<Self>> { let debug = std::env::var_os("FLOW_PR_EDIT_DEBUG").is_some(); let dbg = |msg: &str| { if debug { eprintln!("[pr-edit] {msg}"); } }; dbg("start"); let dir = pr_edit_dir()?; std::fs::create_dir_all(&dir)?; dbg("load index"); let index = load_index(&dir).unwrap_or_default(); let svc = Arc::new(Self { dir, statuses: Arc::new(RwLock::new(HashMap::new())), index: Arc::new(RwLock::new(index)), gh_token: Arc::new(RwLock::new(None)), client: reqwest::Client::builder() .user_agent("flow-pr-edit") .timeout(Duration::from_secs(20)) .build() .context("failed to build GitHub HTTP client")?, }); // Initial scan so status.json exists and the dashboard has something to show. dbg("rescan"); svc.rescan().await?; // Ensure status.json exists even if directory is empty. dbg("write status.json"); let _ = svc.write_status_json().await; // Start watcher thread -> tokio event channel. dbg("spawn watcher thread"); let (tx, rx) = mpsc::channel::<PathBuf>(256); spawn_watcher_thread(svc.dir.clone(), tx)?; // Manager loop: debounce + sync. dbg("spawn manager loop"); let svc_clone = Arc::clone(&svc); tokio::spawn(async move { if let Err(err) = svc_clone.run_loop(rx).await { tracing::warn!(?err, "pr-edit watcher loop exited"); } }); dbg("ready"); Ok(svc) } pub async fn status_snapshot(&self) -> StatusSnapshot { let map = self.statuses.read().await; let mut files: Vec<FileStatus> = map.values().map(|s| s.public.clone()).collect(); files.sort_by(|a, b| a.path.cmp(&b.path)); StatusSnapshot { updated_at_ms: now_ms(), files, } } pub async fn rescan(&self) -> Result<()> { let dir = self.dir.clone(); let files = list_md_files(&dir)?; let idx = self.index.read().await.clone(); let mut scanned: Vec<(PathBuf, String, Option<PrMeta>)> = Vec::with_capacity(files.len()); for path in files { let key = path.to_string_lossy().to_string(); let meta = std::fs::read_to_string(&path) .ok() .and_then(|t| parse_frontmatter(&t)) .or_else(|| idx.files.get(&key).cloned()); scanned.push((path, key, meta)); } let mut statuses = self.statuses.write().await; for (path, key, meta) in scanned { let entry = statuses.entry(path).or_insert_with(|| FileStatusInternal { public: FileStatus { path: key, meta: meta.clone(), state: SyncState::Unknown, last_synced_at_ms: None, last_error: None, }, last_digest_hex: None, }); entry.public.meta = meta; // Don't auto-sync on startup; just show whether mapping exists. entry.public.state = if entry.public.meta.is_some() { SyncState::Clean } else { SyncState::Unknown }; // Preserve last_synced_at_ms / last_error / last_digest_hex from previous runtime. } drop(statuses); self.write_status_json().await?; Ok(()) } async fn run_loop(self: Arc<Self>, mut rx: mpsc::Receiver<PathBuf>) -> Result<()> { let debounce = Duration::from_millis(1250); let mut pending: HashMap<PathBuf, tokio::time::Instant> = HashMap::new(); loop { let next_deadline = pending.values().min().copied(); tokio::select! { maybe_path = rx.recv() => { let Some(path) = maybe_path else { break; }; if path.file_name().and_then(|n| n.to_str()) == Some(INDEX_FILENAME) { let _ = self.reload_index_from_disk().await; // Refresh status snapshot so newly mapped files show meta. let _ = self.write_status_json().await; continue; } if should_ignore_event_path(&self.dir, &path) { continue; } pending.insert(path, tokio::time::Instant::now() + debounce); } _ = async { if let Some(t) = next_deadline { tokio::time::sleep_until(t).await; } else { tokio::time::sleep(Duration::from_millis(250)).await; } } => { let now = tokio::time::Instant::now(); let due: Vec<PathBuf> = pending .iter() .filter_map(|(p, t)| if *t <= now { Some(p.clone()) } else { None }) .collect(); if due.is_empty() { continue; } for p in &due { pending.remove(p); } let mut any_changed = false; for path in due { if let Err(err) = self.sync_file(&path).await { tracing::debug!(?err, path=%path.display(), "pr-edit sync failed"); } any_changed = true; } if any_changed { let _ = self.write_status_json().await; } } } } Ok(()) } async fn sync_file(&self, path: &Path) -> Result<()> { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(_) => return Ok(()), // deleted/unreadable }; // Resolve PR identity. let fm = parse_frontmatter(&text); let idx = if fm.is_none() { self.lookup_index(path).await } else { None }; let meta = fm.or(idx); // Parse PR title/body from markdown. let (title, body) = match parse_title_body(&text) { Ok(v) => v, Err(err) => { self.set_error(path, meta, format!("{err:#}")).await; return Ok(()); } }; let digest_hex = compute_digest_hex(&title, &body); { let mut statuses = self.statuses.write().await; let entry = statuses .entry(path.to_path_buf()) .or_insert_with(|| FileStatusInternal { public: FileStatus { path: path.to_string_lossy().to_string(), meta: meta.clone(), state: SyncState::Unknown, last_synced_at_ms: None, last_error: None, }, last_digest_hex: None, }); entry.public.meta = meta.clone(); if entry.last_digest_hex.as_deref() == Some(&digest_hex) && entry.public.last_error.is_none() { entry.public.state = SyncState::Clean; return Ok(()); } entry.public.state = SyncState::Syncing; entry.public.last_error = None; } let Some(meta) = meta else { self.set_error( path, None, "missing PR metadata (add YAML frontmatter with repo/pr)".to_string(), ) .await; return Ok(()); }; // Ensure token exists (cached). let token = match self.get_gh_token().await { Ok(t) => t, Err(err) => { self.set_error(path, Some(meta), format!("{err:#}")).await; return Ok(()); } }; // PATCH the PR issue (PRs are issues too). let url = format!( "https://api.github.com/repos/{}/issues/{}", meta.repo, meta.pr ); let resp = match self .client .patch(url) .bearer_auth(token) .json(&serde_json::json!({ "title": title, "body": body })) .send() .await { Ok(r) => r, Err(err) => { self.set_error( path, Some(meta), format!("GitHub PATCH request failed: {err:#}"), ) .await; return Ok(()); } }; if !resp.status().is_success() { let status = resp.status(); let body_text = resp.text().await.unwrap_or_default(); self.set_error( path, Some(meta), format!("GitHub API error {status}: {body_text}"), ) .await; return Ok(()); } let mut statuses = self.statuses.write().await; let entry = statuses .entry(path.to_path_buf()) .or_insert_with(|| FileStatusInternal { public: FileStatus { path: path.to_string_lossy().to_string(), meta: Some(meta.clone()), state: SyncState::Unknown, last_synced_at_ms: None, last_error: None, }, last_digest_hex: None, }); entry.public.meta = Some(meta); entry.public.state = SyncState::Clean; entry.public.last_synced_at_ms = Some(now_ms()); entry.public.last_error = None; entry.last_digest_hex = Some(digest_hex); Ok(()) } async fn lookup_index(&self, path: &Path) -> Option<PrMeta> { let key = path.to_string_lossy().to_string(); let guard = self.index.read().await; guard.files.get(&key).cloned() } async fn set_error(&self, path: &Path, meta: Option<PrMeta>, err: String) { let mut statuses = self.statuses.write().await; let entry = statuses .entry(path.to_path_buf()) .or_insert_with(|| FileStatusInternal { public: FileStatus { path: path.to_string_lossy().to_string(), meta: meta.clone(), state: SyncState::Error, last_synced_at_ms: None, last_error: Some(err.clone()), }, last_digest_hex: None, }); entry.public.meta = meta; entry.public.state = SyncState::Error; entry.public.last_error = Some(err); } async fn get_gh_token(&self) -> Result<String> { if let Some(t) = self.gh_token.read().await.clone() { return Ok(t); } let out = Command::new("gh") .args(["auth", "token"]) .output() .context("failed to run `gh auth token`")?; if !out.status.success() { bail!("`gh auth token` failed; run `gh auth login`"); } let token = String::from_utf8_lossy(&out.stdout).trim().to_string(); if token.is_empty() { bail!("`gh auth token` returned empty token"); } *self.gh_token.write().await = Some(token.clone()); Ok(token) } async fn write_status_json(&self) -> Result<()> { let snapshot = self.status_snapshot().await; let json = serde_json::to_string_pretty(&snapshot)?; let tmp = self.dir.join(format!(".{STATUS_FILENAME}.tmp")); let out = self.dir.join(STATUS_FILENAME); std::fs::write(&tmp, json)?; // Best-effort atomic replace. let _ = std::fs::rename(&tmp, &out); Ok(()) } pub fn pr_edit_dir_path(&self) -> &Path { &self.dir } async fn reload_index_from_disk(&self) -> Result<()> { let dir = self.dir.clone(); let idx = tokio::task::spawn_blocking(move || load_index(&dir)) .await .context("index reload task panicked")??; *self.index.write().await = idx; Ok(()) } } fn pr_edit_dir() -> Result<PathBuf> { let home = dirs::home_dir().context("could not resolve home directory")?; Ok(home.join(".flow").join("pr-edit")) } fn list_md_files(dir: &Path) -> Result<Vec<PathBuf>> { let mut out = Vec::new(); let entries = std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))?; for ent in entries { let Ok(ent) = ent else { continue; }; let p = ent.path(); if is_md_file(&p) { out.push(p); } } Ok(out) } fn is_md_file(path: &Path) -> bool { if !path.is_file() { return false; } let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false; }; if ext != "md" { return false; } let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.starts_with('.') { return false; } if name.ends_with('~') { return false; } true } fn should_ignore_event_path(dir: &Path, path: &Path) -> bool { // Ignore non-files and non-md updates. if path == dir.join(STATUS_FILENAME) { return true; } let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name == STATUS_FILENAME { return true; } if name.starts_with(".") || name.ends_with("~") || name.ends_with(".swp") || name.ends_with(".tmp") { return true; } if !name.ends_with(".md") { return true; } false } fn spawn_watcher_thread(dir: PathBuf, tx: mpsc::Sender<PathBuf>) -> Result<()> { std::thread::spawn(move || { let (event_tx, event_rx) = std::sync::mpsc::channel(); let mut debouncer = match new_debouncer(Duration::from_millis(250), event_tx) { Ok(d) => d, Err(err) => { tracing::warn!(?err, "failed to init pr-edit watcher"); return; } }; if let Err(err) = debouncer.watcher().watch(&dir, RecursiveMode::NonRecursive) { tracing::warn!(?err, dir=%dir.display(), "failed to watch pr-edit directory"); return; } loop { match event_rx.recv_timeout(Duration::from_millis(500)) { Ok(Ok(events)) => { for e in events { let p = e.path; let is_md = p.extension().and_then(|x| x.to_str()) == Some("md"); let is_index = p.file_name().and_then(|n| n.to_str()) == Some(INDEX_FILENAME); // Only enqueue md files + index updates; manager will do more filtering. if is_md || is_index { let _ = tx.blocking_send(p); } } } Ok(Err(err)) => { tracing::debug!(?err, "pr-edit watcher error"); } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } } }); Ok(()) } fn parse_frontmatter(text: &str) -> Option<PrMeta> { let mut lines = text.lines(); let first = lines.next()?.trim(); if first != "---" { return None; } let mut repo: Option<String> = None; let mut pr: Option<u64> = None; for line in lines { let l = line.trim(); if l == "---" { break; } if let Some(v) = l.strip_prefix("repo:") { let v = v.trim().trim_matches('"').trim_matches('\''); if !v.is_empty() { repo = Some(v.to_string()); } } if let Some(v) = l.strip_prefix("pr:") { let v = v.trim(); if let Ok(n) = v.parse::<u64>() { pr = Some(n); } } } match (repo, pr) { (Some(repo), Some(pr)) => Some(PrMeta { repo, pr }), _ => None, } } fn parse_title_body(text: &str) -> Result<(String, String)> { let mut title: Option<String> = None; let mut body_lines: Vec<String> = Vec::new(); let mut lines = text.lines().peekable(); while let Some(line) = lines.next() { let l = line.trim_end(); if l.trim() == "# Title" { while let Some(nl) = lines.peek() { if nl.trim().is_empty() { lines.next(); } else { break; } } if let Some(nl) = lines.peek() { let t = nl.trim(); if !t.is_empty() { title = Some(t.to_string()); } } continue; } if l.trim() == "# Description" { while let Some(nl) = lines.peek() { if nl.trim().is_empty() { lines.next(); } else { break; } } for rest in lines { body_lines.push(rest.to_string()); } break; } } let title = title.unwrap_or_default().trim().to_string(); if title.is_empty() { bail!("missing PR title (expected a non-empty line under `# Title`)"); } let body = body_lines.join("\n").trim_end().to_string(); Ok((title, body)) } fn write_index(dir: &Path, idx: &IndexFile) -> Result<()> { let path = dir.join(INDEX_FILENAME); let json = serde_json::to_string_pretty(idx)?; std::fs::write(path, json)?; Ok(()) } /// Best-effort helper for other codepaths (e.g. `f pr open edit`) to register mappings for files /// that don't (yet) have frontmatter. pub fn index_upsert_file(path: &Path, repo: &str, pr: u64) -> Result<()> { let dir = pr_edit_dir()?; std::fs::create_dir_all(&dir)?; let mut idx = load_index(&dir).unwrap_or_default(); idx.files.insert( path.to_string_lossy().to_string(), PrMeta { repo: repo.to_string(), pr, }, ); write_index(&dir, &idx) } fn compute_digest_hex(title: &str, body: &str) -> String { let mut hasher = Sha256::new(); hasher.update(title.as_bytes()); hasher.update(b"\n"); hasher.update(body.as_bytes()); hex::encode(hasher.finalize()) } fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|_| Duration::from_secs(0)) .as_millis() as i64 } fn load_index(dir: &Path) -> Result<IndexFile> { let path = dir.join(INDEX_FILENAME); if !path.exists() { return Ok(IndexFile { version: 1, files: HashMap::new(), }); } let text = std::fs::read_to_string(&path)?; let mut parsed: IndexFile = serde_json::from_str(&text)?; if parsed.version == 0 { parsed.version = 1; } Ok(parsed) } ================================================ FILE: src/processes.rs ================================================ use std::collections::hash_map::DefaultHasher; use std::fs::{self, File}; use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::process::Command; use std::thread; use std::time::Duration; use anyhow::{Context, Result, bail}; use crate::cli::{KillOpts, ProcessOpts, TaskLogsOpts}; use crate::projects; use crate::running; use crate::tasks; /// Show running processes for a project (or all projects) pub fn show_project_processes(opts: ProcessOpts) -> Result<()> { if opts.all { show_all_processes() } else { let (config_path, cfg) = tasks::load_project_config(opts.config)?; let canonical = config_path.canonicalize()?; show_processes_for_project(&canonical, cfg.project_name.as_deref()) } } fn show_processes_for_project(config_path: &Path, project_name: Option<&str>) -> Result<()> { let processes = running::get_project_processes(config_path)?; let project_root = config_path.parent().unwrap_or(Path::new(".")); match project_name { Some(name) => println!("Project: {} ({})", name, project_root.display()), None => println!("Project: {}", project_root.display()), } if processes.is_empty() { println!("No running flow processes."); return Ok(()); } println!("Running processes:"); for proc in &processes { let runtime = format_runtime(proc.started_at); println!( " {} [pid: {}, pgid: {}] - {}", proc.task_name, proc.pid, proc.pgid, runtime ); println!(" {}", proc.command); if proc.used_flox { println!(" (flox environment)"); } } Ok(()) } fn show_all_processes() -> Result<()> { let all = running::load_running_processes()?; if all.projects.is_empty() { println!("No running flow processes."); return Ok(()); } for (config_path, processes) in &all.projects { let project_name = processes .first() .and_then(|p| p.project_name.as_deref()) .unwrap_or("unknown"); let project_root = Path::new(config_path) .parent() .map(|p| p.display().to_string()) .unwrap_or_else(|| config_path.clone()); println!("\n{} ({}):", project_name, project_root); for proc in processes { let runtime = format_runtime(proc.started_at); println!(" {} [pid: {}] - {}", proc.task_name, proc.pid, runtime); } } Ok(()) } fn format_runtime(started_at: u128) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let elapsed_secs = ((now.saturating_sub(started_at)) / 1000) as u64; if elapsed_secs < 60 { format!("{}s", elapsed_secs) } else if elapsed_secs < 3600 { format!("{}m {}s", elapsed_secs / 60, elapsed_secs % 60) } else { format!("{}h {}m", elapsed_secs / 3600, (elapsed_secs % 3600) / 60) } } /// Kill processes based on options pub fn kill_processes(opts: KillOpts) -> Result<()> { let (config_path, _cfg) = tasks::load_project_config(opts.config)?; let canonical = config_path.canonicalize()?; if let Some(pid) = opts.pid { kill_by_pid(pid, opts.force, opts.timeout) } else if let Some(task) = &opts.task { kill_by_task(&canonical, task, opts.force, opts.timeout) } else if opts.all { kill_all_for_project(&canonical, opts.force, opts.timeout) } else { bail!("Specify a task name, --pid <pid>, or --all") } } fn kill_by_pid(pid: u32, force: bool, timeout: u64) -> Result<()> { let processes = running::load_running_processes()?; // Find the process entry to get its PGID let entry = processes.projects.values().flatten().find(|p| p.pid == pid); let pgid = entry.map(|e| e.pgid).unwrap_or(pid); let task_name = entry.map(|e| e.task_name.as_str()).unwrap_or("unknown"); terminate_process_group(pgid, force, timeout)?; running::unregister_process(pid)?; println!("Killed {} (pid: {}, pgid: {})", task_name, pid, pgid); Ok(()) } fn kill_by_task(config_path: &Path, task: &str, force: bool, timeout: u64) -> Result<()> { let processes = running::get_project_processes(config_path)?; let matching: Vec<_> = processes.iter().filter(|p| p.task_name == task).collect(); if matching.is_empty() { bail!("No running process found for task '{}'", task); } for proc in matching { terminate_process_group(proc.pgid, force, timeout)?; running::unregister_process(proc.pid)?; println!("Killed {} (pid: {})", proc.task_name, proc.pid); } Ok(()) } fn kill_all_for_project(config_path: &Path, force: bool, timeout: u64) -> Result<()> { let processes = running::get_project_processes(config_path)?; if processes.is_empty() { println!("No running processes to kill."); return Ok(()); } for proc in &processes { terminate_process_group(proc.pgid, force, timeout)?; running::unregister_process(proc.pid)?; println!("Killed {} (pid: {})", proc.task_name, proc.pid); } Ok(()) } fn terminate_process_group(pgid: u32, force: bool, timeout: u64) -> Result<()> { #[cfg(unix)] { if force { // Immediate SIGKILL to process group Command::new("kill") .arg("-KILL") .arg(format!("-{}", pgid)) .status() .context("failed to send SIGKILL")?; } else { // Graceful SIGTERM to process group let _ = Command::new("kill") .arg("-TERM") .arg(format!("-{}", pgid)) .status(); // Wait for process to exit for _ in 0..timeout { thread::sleep(Duration::from_secs(1)); if !running::process_alive(pgid) { return Ok(()); } } // Force kill if still alive if running::process_alive(pgid) { Command::new("kill") .arg("-KILL") .arg(format!("-{}", pgid)) .status() .context("failed to send SIGKILL after timeout")?; } } } #[cfg(windows)] { Command::new("taskkill") .args(["/PID", &pgid.to_string(), "/T", "/F"]) .status() .context("failed to kill process tree")?; } Ok(()) } // ============================================================================ // Task Logs // ============================================================================ fn log_dir() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".config/flow/logs") } fn sanitize_component(raw: &str) -> String { let mut s = String::with_capacity(raw.len()); for ch in raw.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { s.push(ch); } else { s.push('-'); } } s.trim_matches('-').to_lowercase() } fn short_hash(input: &str) -> String { let mut hasher = DefaultHasher::new(); input.hash(&mut hasher); format!("{:x}", hasher.finish()) } fn project_slug(project_root: &Path, project_name: Option<&str>) -> String { let project_root_key = project_root.display().to_string(); let project_root_hash = short_hash(&project_root_key); match project_name { Some(name) => { let clean = sanitize_component(name); if clean.is_empty() { format!("proj-{project_root_hash}") } else { format!("{clean}-{project_root_hash}") } } None => format!("proj-{project_root_hash}"), } } /// Get the log path for a project/task fn get_log_path(project_root: &Path, project_name: Option<&str>, task_name: &str) -> PathBuf { let base = log_dir(); let slug = project_slug(project_root, project_name); let task = { let clean = sanitize_component(task_name); if clean.is_empty() { "task".to_string() } else { clean } }; base.join(slug).join(format!("{task}.log")) } /// Show task logs pub fn show_task_logs(opts: TaskLogsOpts) -> Result<()> { // If task_id is provided, fetch from hub if let Some(ref task_id) = opts.task_id { return show_hub_task_logs(task_id, opts.follow); } if opts.list { return list_available_logs(opts.all); } if opts.all { return show_all_logs(opts.lines); } // Resolve project: --project flag > flow.toml in cwd > active project let (project_root, config_path, project_name) = if let Some(ref name) = opts.project { // Explicit project name match projects::resolve_project(name)? { Some(entry) => (entry.project_root, entry.config_path, Some(entry.name)), None => { bail!( "Project '{}' not found. Use `f projects` to see registered projects.", name ); } } } else if opts.config.exists() { // flow.toml in current directory let (cfg_path, cfg) = tasks::load_project_config(opts.config.clone())?; let canonical = cfg_path.canonicalize().unwrap_or_else(|_| cfg_path.clone()); let root = cfg_path .parent() .unwrap_or(Path::new(".")) .canonicalize() .unwrap_or_else(|_| cfg_path.parent().unwrap_or(Path::new(".")).to_path_buf()); (root, canonical, cfg.project_name) } else if let Some(active) = projects::get_active_project() { // Fall back to active project match projects::resolve_project(&active)? { Some(entry) => (entry.project_root, entry.config_path, Some(entry.name)), None => { bail!( "Active project '{}' not found. Use `f projects` to see registered projects.", active ); } } } else { bail!( "No flow.toml in current directory and no active project set.\nRun a task in a project first, or use: f logs -p <project>" ); }; // If no task specified, try to find available logs - prefer running tasks let task_name = match opts.task { Some(name) => name, None => { let logs = get_project_log_files(&project_root, project_name.as_deref()); if logs.is_empty() { println!("No logs found for this project."); return Ok(()); } // Check for running tasks let running = running::get_project_processes(&config_path).unwrap_or_default(); let running_tasks: Vec<_> = running.iter().map(|p| p.task_name.clone()).collect(); let running_logs: Vec<_> = logs .iter() .filter(|log| running_tasks.contains(log)) .cloned() .collect(); if running_logs.len() == 1 { // Single running task - use it running_logs[0].clone() } else if running_logs.len() > 1 { // Multiple running tasks println!("Multiple running tasks. Specify which to view:"); for log in &running_logs { println!(" f logs {}", log); } return Ok(()); } else if logs.len() == 1 { // No running tasks, but only one log file logs[0].clone() } else { // No running tasks, multiple log files println!("No running tasks. Available logs:"); for log in &logs { println!(" f logs {}", log); } return Ok(()); } } }; let log_path = get_log_path(&project_root, project_name.as_deref(), &task_name); if !log_path.exists() { bail!( "No log file found for task '{}' at {}", task_name, log_path.display() ); } if opts.follow { tail_follow(&log_path, opts.lines, opts.quiet)?; } else { tail_lines(&log_path, opts.lines)?; } Ok(()) } fn show_all_logs(lines: usize) -> Result<()> { let base = log_dir(); if !base.exists() { println!("No logs found at {}", base.display()); return Ok(()); } // Find the most recently modified log file let mut newest: Option<(PathBuf, u64)> = None; for entry in fs::read_dir(&base)? { let entry = entry?; let path = entry.path(); if path.is_dir() { for log_entry in fs::read_dir(&path)? { let log_entry = log_entry?; let log_path = log_entry.path(); if log_path.extension().map(|e| e == "log").unwrap_or(false) { if let Ok(meta) = fs::metadata(&log_path) { let modified = meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); if newest.as_ref().map(|(_, t)| modified > *t).unwrap_or(true) { newest = Some((log_path, modified)); } } } } } } match newest { Some((path, _)) => { println!("Showing most recent log: {}\n", path.display()); tail_lines(&path, lines) } None => { println!("No log files found."); Ok(()) } } } fn list_available_logs(_all: bool) -> Result<()> { let base = log_dir(); if !base.exists() { println!("No logs found at {}", base.display()); return Ok(()); } println!("Available logs in {}:", base.display()); for entry in fs::read_dir(&base)? { let entry = entry?; let path = entry.path(); if path.is_dir() { let project_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); println!("\n{}:", project_name); for log_entry in fs::read_dir(&path)? { let log_entry = log_entry?; let log_path = log_entry.path(); if log_path.extension().map(|e| e == "log").unwrap_or(false) { let task_name = log_path .file_stem() .and_then(|n| n.to_str()) .unwrap_or("unknown"); let metadata = fs::metadata(&log_path)?; let size = metadata.len(); let modified = metadata .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let age = format_relative_time(now.saturating_sub(modified)); println!(" {} ({} bytes, modified {})", task_name, size, age); } } } } Ok(()) } fn format_relative_time(seconds: u64) -> String { if seconds < 60 { format!("{}s ago", seconds) } else if seconds < 3600 { format!("{}m ago", seconds / 60) } else if seconds < 86400 { format!("{}h ago", seconds / 3600) } else { format!("{}d ago", seconds / 86400) } } /// Get list of task names that have log files for a project fn get_project_log_files(project_root: &Path, project_name: Option<&str>) -> Vec<String> { let base = log_dir(); let slug = project_slug(project_root, project_name); let project_log_dir = base.join(&slug); if !project_log_dir.exists() { return Vec::new(); } let mut tasks = Vec::new(); if let Ok(entries) = fs::read_dir(&project_log_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().map(|e| e == "log").unwrap_or(false) { if let Some(task_name) = path.file_stem().and_then(|n| n.to_str()) { tasks.push(task_name.to_string()); } } } } tasks } fn tail_lines(path: &Path, n: usize) -> Result<()> { let file = File::open(path).context("failed to open log file")?; let reader = BufReader::new(file); let lines: Vec<String> = reader.lines().filter_map(|l| l.ok()).collect(); let start = lines.len().saturating_sub(n); for line in &lines[start..] { println!("{}", line); } Ok(()) } fn tail_follow(path: &Path, initial_lines: usize, quiet: bool) -> Result<()> { // First show the last N lines tail_lines(path, initial_lines)?; // Then follow let mut file = File::open(path).context("failed to open log file")?; file.seek(SeekFrom::End(0))?; if !quiet { println!("\n--- Following {} (Ctrl+C to stop) ---", path.display()); } let mut buf = vec![0u8; 4096]; loop { match file.read(&mut buf) { Ok(0) => { // No new data, sleep and retry thread::sleep(Duration::from_millis(100)); } Ok(n) => { print!("{}", String::from_utf8_lossy(&buf[..n])); } Err(e) => { bail!("Error reading log file: {}", e); } } } } /// Fetch and display logs for a hub task by ID fn show_hub_task_logs(task_id: &str, follow: bool) -> Result<()> { use reqwest::blocking::Client; use serde::Deserialize; const HUB_HOST: &str = "127.0.0.1"; const HUB_PORT: u16 = 9050; #[derive(Debug, Deserialize)] struct TaskLog { id: String, name: String, command: String, cwd: Option<String>, #[allow(dead_code)] started_at: u64, finished_at: Option<u64>, exit_code: Option<i32>, output: Vec<OutputLine>, } #[derive(Debug, Deserialize)] struct OutputLine { #[allow(dead_code)] timestamp_ms: u64, stream: String, line: String, } let url = format!("http://{}:{}/tasks/logs/{}", HUB_HOST, HUB_PORT, task_id); let client = Client::builder() .timeout(Duration::from_secs(5)) .build() .context("failed to create HTTP client")?; if follow { // Poll for updates let mut last_output_count = 0; loop { let resp = client.get(&url).send(); match resp { Ok(r) if r.status().is_success() => { let log: TaskLog = r.json().context("failed to parse task log")?; // Print new output lines for line in log.output.iter().skip(last_output_count) { let prefix = if line.stream == "stderr" { "!" } else { " " }; println!("{} {}", prefix, line.line); } last_output_count = log.output.len(); // Check if task is done if log.finished_at.is_some() { if let Some(code) = log.exit_code { if code == 0 { println!("\n✓ Task completed successfully"); } else { println!("\n✗ Task failed with exit code {}", code); } } break; } } Ok(r) if r.status().as_u16() == 404 => { // Task not found yet, wait thread::sleep(Duration::from_millis(200)); continue; } Ok(r) => { bail!("Hub returned error: {}", r.status()); } Err(e) => { bail!("Failed to fetch task logs: {}", e); } } thread::sleep(Duration::from_millis(500)); } } else { // One-shot fetch let resp = client .get(&url) .send() .context("failed to fetch task logs")?; if resp.status().as_u16() == 404 { println!( "Task '{}' not found yet (queued). Streaming logs...", task_id ); return show_hub_task_logs(task_id, true); } if !resp.status().is_success() { bail!("Hub returned error: {}", resp.status()); } let log: TaskLog = resp.json().context("failed to parse task log")?; println!("Task: {} ({})", log.name, log.id); println!("Command: {}", log.command); if let Some(cwd) = &log.cwd { println!("Working dir: {}", cwd); } println!(); for line in &log.output { let prefix = if line.stream == "stderr" { "!" } else { " " }; println!("{} {}", prefix, line.line); } if let Some(code) = log.exit_code { println!(); if code == 0 { println!("✓ Exit code: {}", code); } else { println!("✗ Exit code: {}", code); } } else { println!("\n⋯ Task still running..."); } } Ok(()) } ================================================ FILE: src/project_snapshot.rs ================================================ use std::{ fs, path::{Path, PathBuf}, time::UNIX_EPOCH, }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::{ai_tasks, discover}; const SNAPSHOT_CACHE_VERSION: u32 = 1; const SNAPSHOT_CACHE_ENV_DISABLE: &str = "FLOW_DISABLE_DISCOVERY_CACHE"; #[derive(Debug, Clone)] pub struct ProjectSnapshot { pub root: PathBuf, pub discovery: discover::DiscoveryResult, pub ai_tasks: Vec<ai_tasks::DiscoveredAiTask>, } impl ProjectSnapshot { pub fn from_root_tasks_only(root: &Path) -> Result<Self> { let root = canonicalize_root(root)?; Self::from_canonical_root_tasks_only(root) } pub fn from_task_config(config: &Path, climb_to_default_flow_toml: bool) -> Result<Self> { let root = resolve_project_root_from_config(config, climb_to_default_flow_toml)?; Self::from_canonical_root(root) } pub fn from_task_config_tasks_only( config: &Path, climb_to_default_flow_toml: bool, ) -> Result<Self> { let root = resolve_project_root_from_config(config, climb_to_default_flow_toml)?; Self::from_canonical_root_tasks_only(root) } pub fn from_current_dir(climb_to_flow_toml: bool) -> Result<Self> { let root = resolve_project_root_from_current_dir(climb_to_flow_toml)?; Self::from_canonical_root(root) } pub fn has_any_tasks(&self) -> bool { !self.discovery.tasks.is_empty() || !self.ai_tasks.is_empty() } pub(crate) fn from_canonical_root(root: PathBuf) -> Result<Self> { let (discovery, ai_tasks) = load_or_build_project_sections(&root, true)?; Ok(Self { root, discovery, ai_tasks, }) } pub(crate) fn from_canonical_root_tasks_only(root: PathBuf) -> Result<Self> { let (discovery, _) = load_or_build_project_sections(&root, false)?; Ok(Self { root, discovery, ai_tasks: Vec::new(), }) } } #[derive(Debug, Clone)] pub struct AiTaskSnapshot { pub root: PathBuf, pub tasks: Vec<ai_tasks::DiscoveredAiTask>, } impl AiTaskSnapshot { pub fn from_root(root: &Path) -> Result<Self> { let root = canonicalize_root(root)?; Self::from_canonical_root(root) } pub(crate) fn from_canonical_root(root: PathBuf) -> Result<Self> { let tasks = load_or_build_ai_tasks(&root)?; Ok(Self { root, tasks }) } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct SnapshotCacheEntry { version: u32, discovery: Option<CachedDiscoverySection>, ai_tasks: Option<CachedAiTasksSection>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CachedDiscoverySection { result: discover::DiscoveryResult, watched: Vec<PathStamp>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct CachedAiTasksSection { tasks: Vec<ai_tasks::DiscoveredAiTask>, watched: Vec<PathStamp>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct PathStamp { path: PathBuf, is_dir: bool, len: u64, modified_sec: u64, modified_nsec: u32, } fn load_or_build_project_sections( root: &Path, include_ai_tasks: bool, ) -> Result<(discover::DiscoveryResult, Vec<ai_tasks::DiscoveredAiTask>)> { if cache_disabled() { let discovery = discover::discover_tasks_from_root(root.to_path_buf())?; let ai_tasks = if include_ai_tasks { ai_tasks::discover_tasks_from_root(root.to_path_buf())? } else { Vec::new() }; return Ok((discovery, ai_tasks)); } let cache_path = snapshot_cache_path(root); let mut cache = read_cache_entry(&cache_path).unwrap_or_default(); let mut cache_dirty = false; let discovery = match cache.discovery.as_ref() { Some(section) if stamps_match(§ion.watched) => section.result.clone(), _ => { let artifacts = discover::discover_tasks_from_root_artifacts(root.to_path_buf())?; let result = artifacts.result.clone(); cache.discovery = Some(CachedDiscoverySection { result: artifacts.result, watched: stamps_for_paths(&artifacts.watched_paths), }); cache_dirty = true; result } }; let ai_tasks = if include_ai_tasks { match cache.ai_tasks.as_ref() { Some(section) if stamps_match(§ion.watched) => section.tasks.clone(), _ => { let artifacts = ai_tasks::discover_tasks_from_root_artifacts(root.to_path_buf())?; let tasks = artifacts.tasks.clone(); cache.ai_tasks = Some(CachedAiTasksSection { tasks: artifacts.tasks, watched: stamps_for_paths(&artifacts.watched_paths), }); cache_dirty = true; tasks } } } else { Vec::new() }; if cache_dirty && let Err(err) = write_cache_entry(&cache_path, &cache) { tracing::debug!(path = %cache_path.display(), error = %err, "failed to write project snapshot cache"); } Ok((discovery, ai_tasks)) } fn load_or_build_ai_tasks(root: &Path) -> Result<Vec<ai_tasks::DiscoveredAiTask>> { if cache_disabled() { return ai_tasks::discover_tasks_from_root(root.to_path_buf()); } let cache_path = snapshot_cache_path(root); let mut cache = read_cache_entry(&cache_path).unwrap_or_default(); if let Some(section) = cache.ai_tasks.as_ref() && stamps_match(§ion.watched) { return Ok(section.tasks.clone()); } let artifacts = ai_tasks::discover_tasks_from_root_artifacts(root.to_path_buf())?; let tasks = artifacts.tasks.clone(); cache.ai_tasks = Some(CachedAiTasksSection { tasks: artifacts.tasks, watched: stamps_for_paths(&artifacts.watched_paths), }); if let Err(err) = write_cache_entry(&cache_path, &cache) { tracing::debug!(path = %cache_path.display(), error = %err, "failed to write AI task snapshot cache"); } Ok(tasks) } fn cache_disabled() -> bool { matches!( std::env::var(SNAPSHOT_CACHE_ENV_DISABLE) .ok() .as_deref() .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), Some("1" | "true" | "yes" | "on") ) } fn snapshot_cache_path(root: &Path) -> PathBuf { let hash = blake3::hash(root.to_string_lossy().as_bytes()).to_hex(); crate::config::global_state_dir() .join("project-snapshot-cache") .join(format!("{hash}.msgpack")) } fn read_cache_entry(path: &Path) -> Option<SnapshotCacheEntry> { let bytes = fs::read(path).ok()?; let cache = rmp_serde::from_slice::<SnapshotCacheEntry>(&bytes).ok()?; if cache.version != SNAPSHOT_CACHE_VERSION { return None; } Some(cache) } fn write_cache_entry(path: &Path, cache: &SnapshotCacheEntry) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create snapshot cache dir {}", parent.display()))?; } let mut cache = cache.clone(); cache.version = SNAPSHOT_CACHE_VERSION; let bytes = rmp_serde::to_vec(&cache).context("failed to encode snapshot cache")?; let tmp_path = path.with_extension(format!("msgpack.tmp.{}", std::process::id())); fs::write(&tmp_path, bytes) .with_context(|| format!("failed to write snapshot cache {}", tmp_path.display()))?; if let Err(err) = fs::rename(&tmp_path, path) { if path.exists() { let _ = fs::remove_file(path); fs::rename(&tmp_path, path) .with_context(|| format!("failed to finalize snapshot cache {}", path.display()))?; } else { return Err(err) .with_context(|| format!("failed to finalize snapshot cache {}", path.display())); } } Ok(()) } fn stamps_for_paths(paths: &[PathBuf]) -> Vec<PathStamp> { let mut stamps: Vec<PathStamp> = paths .iter() .filter_map(|path| PathStamp::capture(path)) .collect(); stamps.sort_by(|a, b| a.path.cmp(&b.path)); stamps.dedup_by(|a, b| a.path == b.path); stamps } fn stamps_match(stamps: &[PathStamp]) -> bool { stamps.iter().all(PathStamp::matches_current) } impl PathStamp { fn capture(path: &Path) -> Option<Self> { let metadata = fs::metadata(path).ok()?; let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?; Some(Self { path: path.to_path_buf(), is_dir: metadata.is_dir(), len: if metadata.is_file() { metadata.len() } else { 0 }, modified_sec: modified.as_secs(), modified_nsec: modified.subsec_nanos(), }) } fn matches_current(&self) -> bool { let Some(current) = Self::capture(&self.path) else { return false; }; current.is_dir == self.is_dir && current.len == self.len && current.modified_sec == self.modified_sec && current.modified_nsec == self.modified_nsec } } pub fn canonicalize_root(root: &Path) -> Result<PathBuf> { let root = if root.is_absolute() { root.to_path_buf() } else { std::env::current_dir()?.join(root) }; Ok(root.canonicalize().unwrap_or(root)) } pub fn resolve_project_root_from_current_dir(climb_to_flow_toml: bool) -> Result<PathBuf> { let root = std::env::current_dir()?; resolve_project_root_from_start(root, climb_to_flow_toml) } pub fn resolve_project_root_from_config( config: &Path, climb_to_default_flow_toml: bool, ) -> Result<PathBuf> { let resolved_config = if config.is_absolute() { config.to_path_buf() } else { std::env::current_dir()?.join(config) }; let root = resolved_config .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); let climb = climb_to_default_flow_toml && is_default_flow_config(config); resolve_project_root_from_start(root, climb) } pub fn is_default_flow_config(path: &Path) -> bool { path.file_name().and_then(|name| name.to_str()) == Some("flow.toml") } pub fn find_flow_toml_upwards(start: &Path) -> Option<PathBuf> { let mut current = start.to_path_buf(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } fn resolve_project_root_from_start(start: PathBuf, climb_to_flow_toml: bool) -> Result<PathBuf> { let root = if climb_to_flow_toml && !start.join("flow.toml").exists() { find_flow_toml_upwards(&start) .and_then(|found| found.parent().map(|p| p.to_path_buf())) .unwrap_or(start) } else { start }; Ok(root.canonicalize().unwrap_or(root)) } #[cfg(test)] mod tests { use std::{fs, path::Path, thread, time::Duration}; use tempfile::tempdir; use super::{PathStamp, find_flow_toml_upwards, resolve_project_root_from_config}; struct CurrentDirGuard(std::path::PathBuf); impl Drop for CurrentDirGuard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.0); } } #[test] fn find_flow_toml_upwards_finds_nearest_ancestor() { let dir = tempdir().expect("tempdir"); let root = dir.path().join("repo"); let nested = root.join("a/b/c"); fs::create_dir_all(&nested).expect("nested dir"); fs::write(root.join("flow.toml"), "version = 1\nname = \"t\"\n").expect("flow.toml"); let found = find_flow_toml_upwards(&nested).expect("should find ancestor flow.toml"); assert_eq!(found, root.join("flow.toml")); } #[test] fn resolve_project_root_from_absolute_config_uses_parent() { let dir = tempdir().expect("tempdir"); let root = dir.path().join("repo"); fs::create_dir_all(&root).expect("repo dir"); let config = root.join("flow.toml"); fs::write(&config, "version = 1\nname = \"t\"\n").expect("flow.toml"); let resolved = resolve_project_root_from_config(&config, true).expect("absolute config resolves"); assert_eq!( resolved, root.canonicalize().unwrap_or(root.clone()), "absolute config should resolve to its parent" ); } #[test] fn resolve_project_root_from_relative_config_uses_relative_parent() { let dir = tempdir().expect("tempdir"); let root = dir.path().join("repo"); let nested = root.join("nested"); fs::create_dir_all(&nested).expect("nested dir"); fs::write(nested.join("flow.toml"), "version = 1\nname = \"t\"\n").expect("flow.toml"); let previous = std::env::current_dir().expect("current dir"); let _guard = CurrentDirGuard(previous); std::env::set_current_dir(&root).expect("set current dir"); let resolved = resolve_project_root_from_config(Path::new("nested/flow.toml"), false) .expect("relative config resolves"); assert_eq!( resolved, nested.canonicalize().unwrap_or(nested.clone()), "relative config should resolve to its file parent" ); } #[test] fn path_stamp_detects_file_changes() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("flow.toml"); fs::write(&path, "a = 1\n").expect("write file"); let stamp = PathStamp::capture(&path).expect("capture stamp"); thread::sleep(Duration::from_millis(5)); fs::write(&path, "a = 11\n").expect("rewrite file"); assert!( !stamp.matches_current(), "file content changes should invalidate the cache stamp" ); } } ================================================ FILE: src/projects.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; use crate::cli::ActiveOpts; use crate::{db, running}; /// Single project record. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectEntry { pub name: String, pub project_root: PathBuf, pub config_path: PathBuf, pub updated_ms: u128, } /// Persist the project name -> path mapping. Idempotent. pub fn register_project(name: &str, config_path: &Path) -> Result<()> { let canonical_config = config_path .canonicalize() .unwrap_or_else(|_| config_path.to_path_buf()); let project_root = config_path .parent() .unwrap_or(Path::new(".")) .canonicalize() .unwrap_or_else(|_| config_path.parent().unwrap_or(Path::new(".")).to_path_buf()); let conn = open_db()?; create_schema(&conn)?; conn.execute( r#" INSERT INTO projects (name, project_root, config_path, updated_ms) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(name) DO UPDATE SET project_root=excluded.project_root, config_path=excluded.config_path, updated_ms=excluded.updated_ms "#, params![ name, project_root.to_string_lossy(), canonical_config.to_string_lossy(), running::now_ms() as i64 ], ) .context("failed to upsert project")?; Ok(()) } /// Return the most recent entry for a given project name, if present. pub fn resolve_project(name: &str) -> Result<Option<ProjectEntry>> { let conn = open_db()?; create_schema(&conn)?; let mut stmt = conn.prepare( "SELECT name, project_root, config_path, updated_ms FROM projects WHERE name = ?1", )?; let mut rows = stmt.query([name])?; if let Some(row) = rows.next()? { let entry = ProjectEntry { name: row.get(0)?, project_root: PathBuf::from(row.get::<_, String>(1)?), config_path: PathBuf::from(row.get::<_, String>(2)?), updated_ms: row.get::<_, i64>(3)? as u128, }; Ok(Some(entry)) } else { Ok(None) } } /// List all registered projects, ordered by most recently updated. pub fn list_projects() -> Result<Vec<ProjectEntry>> { let conn = open_db()?; create_schema(&conn)?; let mut stmt = conn.prepare( "SELECT name, project_root, config_path, updated_ms FROM projects ORDER BY updated_ms DESC", )?; let mut rows = stmt.query([])?; let mut entries = Vec::new(); while let Some(row) = rows.next()? { entries.push(ProjectEntry { name: row.get(0)?, project_root: PathBuf::from(row.get::<_, String>(1)?), config_path: PathBuf::from(row.get::<_, String>(2)?), updated_ms: row.get::<_, i64>(3)? as u128, }); } Ok(entries) } /// Print all registered projects. pub fn show_projects() -> Result<()> { let projects = list_projects()?; if projects.is_empty() { println!("No registered projects."); println!("Projects are registered when you run a task in a flow.toml with a 'name' field."); return Ok(()); } println!("Registered projects:\n"); for entry in &projects { let age = format_age(entry.updated_ms); println!(" {} ({})", entry.name, age); println!(" {}", entry.project_root.display()); } Ok(()) } fn format_age(timestamp_ms: u128) -> String { let now = running::now_ms(); let elapsed_secs = ((now.saturating_sub(timestamp_ms)) / 1000) as u64; if elapsed_secs < 60 { format!("{}s ago", elapsed_secs) } else if elapsed_secs < 3600 { format!("{}m ago", elapsed_secs / 60) } else if elapsed_secs < 86400 { format!("{}h ago", elapsed_secs / 3600) } else { format!("{}d ago", elapsed_secs / 86400) } } fn open_db() -> Result<Connection> { db::open_db() } fn create_schema(conn: &Connection) -> Result<()> { conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS projects ( name TEXT PRIMARY KEY, project_root TEXT NOT NULL, config_path TEXT NOT NULL, updated_ms INTEGER NOT NULL ); "#, ) .context("failed to create schema")?; Ok(()) } // ============================================================================ // Active Project // ============================================================================ fn active_project_path() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".config/flow/active_project") } /// Set the active project name. pub fn set_active_project(name: &str) -> Result<()> { let path = active_project_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).context("failed to create config dir")?; } fs::write(&path, name).context("failed to write active project")?; Ok(()) } /// Get the current active project name, if set. pub fn get_active_project() -> Option<String> { let path = active_project_path(); fs::read_to_string(&path) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } /// Clear the active project. pub fn clear_active_project() -> Result<()> { let path = active_project_path(); if path.exists() { fs::remove_file(&path).context("failed to remove active project")?; } Ok(()) } /// Handle the `f active` command. pub fn handle_active(opts: ActiveOpts) -> Result<()> { if opts.clear { clear_active_project()?; println!("Active project cleared."); return Ok(()); } if let Some(name) = opts.project { // Verify project exists if resolve_project(&name)?.is_none() { anyhow::bail!( "Project '{}' not found. Use `f projects` to see registered projects.", name ); } set_active_project(&name)?; println!("Active project set to: {}", name); return Ok(()); } // Show current active project match get_active_project() { Some(name) => println!("{}", name), None => println!("No active project set."), } Ok(()) } ================================================ FILE: src/proxy/mod.rs ================================================ //! proxyx - Zero-cost traced reverse proxy for Flow. //! //! This module provides a lightweight reverse proxy with always-on observability //! designed for macOS development. Key features: //! //! - **Zero-cost tracing** via mmap ring buffer (no allocations per request) //! - **Agent-readable summary** JSON file for AI assistants //! - **Trace ID propagation** across services //! - **Flow integration** via flow.toml configuration pub mod server; pub mod summary; pub mod trace; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use server::{Backend, ProxyRouter, ProxyServer}; use summary::{SummaryState, SummaryWriter}; use trace::TraceBuffer; /// Proxy configuration from flow.toml #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct ProxyConfig { /// Listen address (e.g., ":8080" or "127.0.0.1:8080") #[serde(default = "default_listen")] pub listen: String, /// Trace ring buffer size (e.g., "16MB") #[serde(default = "default_trace_size")] pub trace_size: String, /// Trace directory #[serde(default)] pub trace_dir: Option<String>, /// Write agent-readable summary JSON #[serde(default = "default_true")] pub trace_summary: bool, /// Summary update interval (e.g., "1s") #[serde(default = "default_summary_interval")] pub summary_interval: String, /// Slow request threshold in milliseconds #[serde(default = "default_slow_threshold")] pub slow_threshold_ms: u32, } fn default_listen() -> String { "127.0.0.1:8080".to_string() } fn default_trace_size() -> String { "16MB".to_string() } fn default_true() -> bool { true } fn default_summary_interval() -> String { "1s".to_string() } fn default_slow_threshold() -> u32 { 500 } /// Individual proxy target configuration #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProxyTargetConfig { /// Unique name for this proxy pub name: String, /// Target address (e.g., "localhost:3000") pub target: String, /// Optional host-based routing #[serde(default)] pub host: Option<String>, /// Optional path prefix routing #[serde(default)] pub path: Option<String>, /// Capture request/response bodies #[serde(default)] pub capture_body: bool, /// Max body size to capture #[serde(default = "default_capture_max")] pub capture_body_max: String, /// Health check path #[serde(default)] pub health: Option<String>, /// Paths to exclude from tracing #[serde(default)] pub exclude_paths: Vec<String>, } fn default_capture_max() -> String { "64KB".to_string() } /// Parse size string (e.g., "16MB") to bytes pub fn parse_size(s: &str) -> usize { let s = s.trim().to_uppercase(); if let Some(num) = s.strip_suffix("GB") { num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 * 1024 } else if let Some(num) = s.strip_suffix("MB") { num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 } else if let Some(num) = s.strip_suffix("KB") { num.trim().parse::<usize>().unwrap_or(0) * 1024 } else if let Some(num) = s.strip_suffix("B") { num.trim().parse::<usize>().unwrap_or(0) } else { s.parse::<usize>().unwrap_or(16 * 1024 * 1024) } } /// Parse duration string (e.g., "1s", "500ms") to Duration pub fn parse_duration(s: &str) -> Duration { let s = s.trim().to_lowercase(); if let Some(num) = s.strip_suffix("ms") { Duration::from_millis(num.trim().parse().unwrap_or(1000)) } else if let Some(num) = s.strip_suffix('s') { Duration::from_secs(num.trim().parse().unwrap_or(1)) } else if let Some(num) = s.strip_suffix('m') { Duration::from_secs(num.trim().parse::<u64>().unwrap_or(1) * 60) } else { Duration::from_secs(s.parse().unwrap_or(1)) } } /// Start the proxy server with the given configuration pub async fn start(config: ProxyConfig, targets: Vec<ProxyTargetConfig>) -> Result<()> { // Parse listen address let listen_addr: SocketAddr = if config.listen.starts_with(':') { format!("127.0.0.1{}", config.listen).parse() } else { config.listen.parse() } .context("Invalid listen address")?; // Initialize trace buffer let trace_dir = config .trace_dir .as_ref() .map(|s| PathBuf::from(shellexpand::tilde(s).to_string())) .unwrap_or_else(trace::default_trace_dir); let trace_size = parse_size(&config.trace_size); let trace_buffer = TraceBuffer::init(&trace_dir, trace_size).context("Failed to initialize trace buffer")?; let trace_buffer = Arc::new(trace_buffer); // Build backends let mut backends = Vec::new(); for (idx, target) in targets.iter().enumerate() { let addr: SocketAddr = if target.target.contains(':') { target.target.parse() } else { format!("127.0.0.1:{}", target.target).parse() } .context(format!("Invalid target address: {}", target.target))?; backends.push(Backend { name: target.name.clone(), addr, index: idx as u8, }); } // Build router let mut router = ProxyRouter::new(backends); for (idx, target) in targets.iter().enumerate() { if let Some(host) = &target.host { router.add_host_route(host.clone(), idx); } if let Some(path) = &target.path { router.add_path_route(path.clone(), idx); } } // Create summary state let target_names = router.backend_names(); let summary_state = Arc::new(SummaryState::new(target_names, config.slow_threshold_ms)); // Create server let server = Arc::new(ProxyServer::new( router, trace_buffer.clone(), summary_state.clone(), )); // Start summary writer if enabled if config.trace_summary { let summary_path = trace_dir.join("trace-summary.json"); let interval = parse_duration(&config.summary_interval); let writer = SummaryWriter::new( trace_buffer.clone(), summary_state.clone(), summary_path.clone(), interval, ); writer.spawn(); tracing::info!("Summary writer started: {:?}", summary_path); } // Print startup info println!("proxyx listening on {}", listen_addr); println!("Trace buffer: {:?} ({} bytes)", trace_dir, trace_size); println!("Targets:"); for target in &targets { println!(" {} -> {}", target.name, target.target); } // Run server server::run_server(listen_addr, server).await } /// CLI command to view recent traces pub fn trace_last(count: usize) -> Result<()> { let trace_dir = trace::default_trace_dir(); // Find the trace file let entries = std::fs::read_dir(&trace_dir)?; let trace_file = entries .filter_map(|e| e.ok()) .find(|e| { e.file_name() .to_str() .map(|s| s.starts_with("trace.") && s.ends_with(".bin")) .unwrap_or(false) }) .context("No trace file found")?; // Memory-map and read let file = std::fs::File::open(trace_file.path())?; let size = file.metadata()?.len() as usize; let buffer = TraceBuffer::init(&trace_dir, size).context("Failed to open trace buffer")?; let records = buffer.recent(count); println!( "{:<12} {:<8} {:<6} {:<40} {:<6} {:<10} {:<10}", "TIME", "REQ_ID", "METHOD", "PATH", "STATUS", "LATENCY", "TARGET" ); println!("{}", "-".repeat(100)); for record in records { if record.timestamp() == 0 { continue; } println!( "{:<12} {:<8x} {:<6} {:<40} {:<6} {:<10} {:<10}", format!("{}ms ago", record.timestamp() / 1_000_000), record.req_id(), format!("{:?}", record.method()), truncate_path(record.path(), 40), record.status(), format!("{}ms", record.latency_us() / 1000), record.target_idx(), ); } Ok(()) } fn truncate_path(path: &str, max_len: usize) -> String { if path.len() <= max_len { path.to_string() } else { format!("{}...", &path[..max_len - 3]) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_size() { assert_eq!(parse_size("16MB"), 16 * 1024 * 1024); assert_eq!(parse_size("1GB"), 1024 * 1024 * 1024); assert_eq!(parse_size("64KB"), 64 * 1024); assert_eq!(parse_size("1024"), 1024); } #[test] fn test_parse_duration() { assert_eq!(parse_duration("1s"), Duration::from_secs(1)); assert_eq!(parse_duration("500ms"), Duration::from_millis(500)); assert_eq!(parse_duration("5m"), Duration::from_secs(300)); } } ================================================ FILE: src/proxy/server.rs ================================================ //! HTTP reverse proxy server. //! //! A lightweight proxy that forwards requests to backends and records traces. use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use anyhow::{Context, Result}; use axum::Router; use axum::body::Body; use axum::extract::State; use axum::http::{Request, Response, StatusCode}; use axum::routing::any; use tokio::sync::RwLock; use super::summary::SummaryState; use super::trace::{TraceBuffer, TraceRecord, hash_path, now_ns}; /// A backend target #[derive(Debug, Clone)] pub struct Backend { pub name: String, pub addr: SocketAddr, pub index: u8, } /// Routing configuration pub struct ProxyRouter { /// Host header -> backend index pub host_routes: HashMap<String, usize>, /// Path prefix -> backend index (checked in order) pub path_routes: Vec<(String, usize)>, /// Default backend (if no route matches) pub default: Option<usize>, /// All backends pub backends: Vec<Backend>, } impl ProxyRouter { pub fn new(backends: Vec<Backend>) -> Self { Self { host_routes: HashMap::new(), path_routes: Vec::new(), default: if backends.is_empty() { None } else { Some(0) }, backends, } } pub fn add_host_route(&mut self, host: String, backend_idx: usize) { self.host_routes.insert(host, backend_idx); } pub fn add_path_route(&mut self, prefix: String, backend_idx: usize) { self.path_routes.push((prefix, backend_idx)); } pub fn route(&self, host: Option<&str>, path: &str) -> Option<&Backend> { // 1. Check host header if let Some(host_str) = host { // Strip port if present let host_name = host_str.split(':').next().unwrap_or(host_str); if let Some(&idx) = self.host_routes.get(host_name) { return self.backends.get(idx); } } // 2. Check path prefix for (prefix, idx) in &self.path_routes { if path.starts_with(prefix) { return self.backends.get(*idx); } } // 3. Default self.default.and_then(|idx| self.backends.get(idx)) } pub fn backend_names(&self) -> Vec<String> { self.backends.iter().map(|b| b.name.clone()).collect() } } /// Proxy server state pub struct ProxyServer { pub router: RwLock<ProxyRouter>, pub trace_buffer: Arc<TraceBuffer>, pub summary_state: Arc<SummaryState>, pub client: reqwest::Client, pub trace_id_counter: AtomicU64, } impl ProxyServer { pub fn new( router: ProxyRouter, trace_buffer: Arc<TraceBuffer>, summary_state: Arc<SummaryState>, ) -> Self { let client = reqwest::Client::builder() .pool_max_idle_per_host(10) .build() .expect("Failed to create HTTP client"); Self { router: RwLock::new(router), trace_buffer, summary_state, client, trace_id_counter: AtomicU64::new(1), } } /// Generate a new trace ID pub fn next_trace_id(&self) -> u128 { self.trace_id_counter.fetch_add(1, Ordering::Relaxed) as u128 } } /// Handle proxied requests async fn proxy_handler( State(server): State<Arc<ProxyServer>>, req: Request<Body>, ) -> Response<Body> { let start = Instant::now(); let start_ns = now_ns(); let req_id = server.trace_buffer.next_req_id(); // Get or generate trace ID let trace_id = req .headers() .get("x-trace-id") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::<u128>().ok()) .unwrap_or_else(|| server.next_trace_id()); let method = req.method().clone(); let uri = req.uri().clone(); let path = uri.path().to_string(); let method_str = method.as_str(); let host = req .headers() .get("host") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); // Route to backend let router = server.router.read().await; let backend = match router.route(host.as_deref(), &path) { Some(b) => b.clone(), None => { drop(router); // No route found let mut record = TraceRecord::new(); record.set_timestamp(start_ns); record.set_req_id(req_id); record.set_latency_status( start.elapsed().as_micros() as u32, 502, method_str.into(), 0, ); record.set_path(&path); record.set_path_hash(hash_path(&path)); server.trace_buffer.record(&record); return Response::builder() .status(StatusCode::BAD_GATEWAY) .body(Body::from("No backend configured")) .unwrap(); } }; drop(router); // Build upstream URL let upstream_url = format!( "http://{}{}{}", backend.addr, path, uri.query().map(|q| format!("?{}", q)).unwrap_or_default() ); // Forward request headers let upstream_start = Instant::now(); let mut upstream_req = server.client.request(method.clone(), &upstream_url); // Copy headers (except host) for (name, value) in req.headers() { if name != "host" { if let Ok(v) = value.to_str() { upstream_req = upstream_req.header(name.as_str(), v); } } } // Add trace ID header upstream_req = upstream_req.header("x-trace-id", trace_id.to_string()); // Get request body let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024) .await .ok(); let bytes_in = body_bytes.as_ref().map(|b| b.len()).unwrap_or(0) as u32; // Send body if present if let Some(body) = body_bytes { if !body.is_empty() { upstream_req = upstream_req.body(body.to_vec()); } } // Execute request let result = upstream_req.send().await; let upstream_latency_us = upstream_start.elapsed().as_micros() as u32; let (status, body, bytes_out) = match result { Ok(resp) => { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); let bytes_out = body.len() as u32; // Store error body for AI analysis if status >= 400 { server.summary_state.store_error_body(req_id, body.clone()); } (status, body, bytes_out) } Err(e) => { let error_body = format!("{{\"error\": \"{}\"}}", e); server .summary_state .store_error_body(req_id, error_body.clone()); (502, error_body, 0) } }; let total_latency_us = start.elapsed().as_micros() as u32; // Record trace let mut record = TraceRecord::new(); record.set_timestamp(start_ns); record.set_req_id(req_id); record.set_latency_status(total_latency_us, status, method_str.into(), 0); record.set_bytes(bytes_in, bytes_out); record.set_target_and_trace_id(backend.index, path.len().min(255) as u8, trace_id); record.set_path_hash(hash_path(&path)); record.set_upstream_latency(upstream_latency_us); record.set_path(&path); server.trace_buffer.record(&record); // Build response Response::builder() .status(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)) .header("x-trace-id", trace_id.to_string()) .header("x-proxy-latency-ms", (total_latency_us / 1000).to_string()) .header("content-type", "application/json") .body(Body::from(body)) .unwrap() } /// Health check endpoint async fn health_handler(State(server): State<Arc<ProxyServer>>) -> Response<Body> { let router = server.router.read().await; let backends: Vec<_> = router .backends .iter() .map(|b| { serde_json::json!({ "name": b.name, "addr": b.addr.to_string(), }) }) .collect(); let stats = serde_json::json!({ "status": "ok", "total_requests": server.trace_buffer.write_index(), "backends": backends, }); Response::builder() .status(StatusCode::OK) .header("content-type", "application/json") .body(Body::from(serde_json::to_string_pretty(&stats).unwrap())) .unwrap() } /// Create the axum router pub fn create_router(server: Arc<ProxyServer>) -> Router { Router::new() .route("/_proxy/health", axum::routing::get(health_handler)) .fallback(any(proxy_handler)) .with_state(server) } /// Run the proxy server pub async fn run_server(addr: SocketAddr, server: Arc<ProxyServer>) -> Result<()> { let app = create_router(server); let listener = tokio::net::TcpListener::bind(addr) .await .context("Failed to bind proxy server")?; tracing::info!("Proxy server listening on {}", addr); axum::serve(listener, app) .await .context("Proxy server error")?; Ok(()) } ================================================ FILE: src/proxy/summary.rs ================================================ //! Agent-readable trace summary. //! //! Writes a JSON file that AI agents (like Claude Code) can read to understand //! the current state of the application during development. use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::Serialize; use super::trace::{TraceBuffer, TraceRecord}; /// Summary of a single error for AI consumption #[derive(Debug, Clone, Serialize)] pub struct ErrorSummary { pub time: String, pub req_id: String, pub method: String, pub path: String, pub status: u16, pub latency_ms: u32, pub target: String, #[serde(skip_serializing_if = "Option::is_none")] pub error_body: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub suggestion: Option<String>, } /// Summary of a slow request for AI consumption #[derive(Debug, Clone, Serialize)] pub struct SlowRequestSummary { pub time: String, pub req_id: String, pub method: String, pub path: String, pub status: u16, pub latency_ms: u32, pub upstream_latency_ms: u32, pub target: String, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, } /// Health status for a target/provider #[derive(Debug, Clone, Serialize)] pub struct TargetHealth { pub healthy: bool, pub total_requests: u64, pub error_count: u64, pub error_rate: String, pub avg_latency_ms: u32, #[serde(skip_serializing_if = "Option::is_none")] pub last_error: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub last_error_time: Option<String>, } /// Session statistics #[derive(Debug, Clone, Serialize)] pub struct SessionStats { pub started: u64, pub started_human: String, pub uptime_seconds: u64, pub total_requests: u64, pub total_errors: u64, pub error_rate: String, pub avg_latency_ms: u32, pub p99_latency_ms: u32, pub bytes_in: u64, pub bytes_out: u64, } /// The complete trace summary (written to JSON) #[derive(Debug, Clone, Serialize)] pub struct TraceSummary { pub last_updated: u64, pub last_updated_human: String, pub session: SessionStats, pub recent_errors: Vec<ErrorSummary>, pub slow_requests: Vec<SlowRequestSummary>, pub target_health: HashMap<String, TargetHealth>, pub request_patterns: HashMap<String, u64>, } /// State for computing summaries pub struct SummaryState { pub targets: Vec<String>, pub error_bodies: RwLock<HashMap<u64, String>>, pub slow_threshold_ms: u32, pub session_start: Instant, pub session_start_unix: u64, } impl SummaryState { pub fn new(targets: Vec<String>, slow_threshold_ms: u32) -> Self { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); Self { targets, error_bodies: RwLock::new(HashMap::new()), slow_threshold_ms, session_start: Instant::now(), session_start_unix: now, } } /// Store an error response body for a request ID pub fn store_error_body(&self, req_id: u64, body: String) { if let Ok(mut bodies) = self.error_bodies.write() { // Keep only last 100 error bodies if bodies.len() > 100 { // Remove oldest entries (this is O(n) but rare) let to_remove: Vec<_> = bodies.keys().take(50).copied().collect(); for k in to_remove { bodies.remove(&k); } } bodies.insert(req_id, body); } } /// Get error body for a request ID pub fn get_error_body(&self, req_id: u64) -> Option<String> { self.error_bodies .read() .ok() .and_then(|b| b.get(&req_id).cloned()) } /// Get target name by index pub fn target_name(&self, idx: u8) -> &str { self.targets .get(idx as usize) .map(|s| s.as_str()) .unwrap_or("unknown") } } /// Compute a summary from the trace buffer pub fn compute_summary(buffer: &TraceBuffer, state: &SummaryState) -> TraceSummary { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let records = buffer.recent(1000); // Compute session stats let total_requests = buffer.write_index(); let total_errors = records.iter().filter(|r| r.is_error()).count() as u64; let error_rate = if total_requests > 0 { format!( "{:.1}%", (total_errors as f64 / records.len() as f64) * 100.0 ) } else { "0%".to_string() }; let latencies: Vec<u32> = records.iter().map(|r| r.latency_us() / 1000).collect(); let avg_latency_ms = if !latencies.is_empty() { (latencies.iter().map(|&l| l as u64).sum::<u64>() / latencies.len() as u64) as u32 } else { 0 }; let p99_latency_ms = if !latencies.is_empty() { let mut sorted = latencies.clone(); sorted.sort(); let p99_idx = (sorted.len() as f64 * 0.99) as usize; sorted .get(p99_idx.min(sorted.len() - 1)) .copied() .unwrap_or(0) } else { 0 }; let bytes_in: u64 = records.iter().map(|r| r.bytes_in() as u64).sum(); let bytes_out: u64 = records.iter().map(|r| r.bytes_out() as u64).sum(); let uptime = state.session_start.elapsed().as_secs(); let session = SessionStats { started: state.session_start_unix, started_human: format_timestamp(state.session_start_unix), uptime_seconds: uptime, total_requests, total_errors, error_rate, avg_latency_ms, p99_latency_ms, bytes_in, bytes_out, }; // Recent errors (last 10) let recent_errors: Vec<ErrorSummary> = records .iter() .filter(|r| r.is_error()) .take(10) .map(|r| { let error_body = state.get_error_body(r.req_id()); let suggestion = suggest_fix(r, error_body.as_deref()); ErrorSummary { time: format_relative_time(r.timestamp(), buffer.start_time()), req_id: format!("{:x}", r.req_id()), method: format!("{:?}", r.method()), path: r.path().to_string(), status: r.status(), latency_ms: r.latency_us() / 1000, target: state.target_name(r.target_idx()).to_string(), error_body, suggestion, } }) .collect(); // Slow requests (last 10, > threshold) let slow_requests: Vec<SlowRequestSummary> = records .iter() .filter(|r| r.is_slow(state.slow_threshold_ms)) .take(10) .map(|r| { let reason = if r.upstream_latency_us() > state.slow_threshold_ms * 1000 * 80 / 100 { Some("Upstream response slow".to_string()) } else { None }; SlowRequestSummary { time: format_relative_time(r.timestamp(), buffer.start_time()), req_id: format!("{:x}", r.req_id()), method: format!("{:?}", r.method()), path: r.path().to_string(), status: r.status(), latency_ms: r.latency_us() / 1000, upstream_latency_ms: r.upstream_latency_us() / 1000, target: state.target_name(r.target_idx()).to_string(), reason, } }) .collect(); // Target health let mut target_health: HashMap<String, TargetHealth> = HashMap::new(); for target in &state.targets { target_health.insert( target.clone(), TargetHealth { healthy: true, total_requests: 0, error_count: 0, error_rate: "0%".to_string(), avg_latency_ms: 0, last_error: None, last_error_time: None, }, ); } // Compute per-target stats let mut target_latencies: HashMap<u8, Vec<u32>> = HashMap::new(); let mut target_errors: HashMap<u8, (u64, Option<(String, u64)>)> = HashMap::new(); let mut target_counts: HashMap<u8, u64> = HashMap::new(); for r in &records { let idx = r.target_idx(); *target_counts.entry(idx).or_insert(0) += 1; target_latencies .entry(idx) .or_insert_with(Vec::new) .push(r.latency_us() / 1000); if r.is_error() { let entry = target_errors.entry(idx).or_insert((0, None)); entry.0 += 1; if entry.1.is_none() { entry.1 = Some((format!("{} {}", r.status(), r.path()), r.timestamp())); } } } for (idx, count) in target_counts { let target_name = state.target_name(idx); if let Some(health) = target_health.get_mut(target_name) { health.total_requests = count; if let Some(latencies) = target_latencies.get(&idx) { if !latencies.is_empty() { health.avg_latency_ms = (latencies.iter().map(|&l| l as u64).sum::<u64>() / latencies.len() as u64) as u32; } } if let Some((error_count, last_error)) = target_errors.get(&idx) { health.error_count = *error_count; health.error_rate = format!("{:.1}%", (*error_count as f64 / count as f64) * 100.0); health.healthy = (*error_count as f64 / count as f64) < 0.1; // < 10% errors if let Some((err, ts)) = last_error { health.last_error = Some(err.clone()); health.last_error_time = Some(format_relative_time(*ts, buffer.start_time())); } } } } // Request patterns (path -> count) let mut request_patterns: HashMap<String, u64> = HashMap::new(); for r in &records { // Normalize path (remove query params, truncate) let path = r.path().split('?').next().unwrap_or("").to_string(); *request_patterns.entry(path).or_insert(0) += 1; } // Keep only top 20 patterns let mut patterns: Vec<_> = request_patterns.into_iter().collect(); patterns.sort_by(|a, b| b.1.cmp(&a.1)); let request_patterns: HashMap<String, u64> = patterns.into_iter().take(20).collect(); TraceSummary { last_updated: now, last_updated_human: format_timestamp(now), session, recent_errors, slow_requests, target_health, request_patterns, } } /// Write summary to a JSON file pub fn write_summary(summary: &TraceSummary, path: &PathBuf) -> std::io::Result<()> { let json = serde_json::to_string_pretty(summary)?; fs::write(path, json) } /// Get the default summary file path pub fn default_summary_path() -> PathBuf { dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("flow") .join("proxy") .join("trace-summary.json") } // Helper: format Unix timestamp as human-readable fn format_timestamp(ts: u64) -> String { use std::time::{Duration, UNIX_EPOCH}; let dt = UNIX_EPOCH + Duration::from_secs(ts); // Simple format without chrono dependency format!("{:?}", dt) } // Helper: format nanosecond timestamp relative to start fn format_relative_time(ts_ns: u64, start: Instant) -> String { // Convert to wall clock time (approximate) let elapsed = start.elapsed(); let now_ns = elapsed.as_nanos() as u64; if ts_ns > now_ns { return "now".to_string(); } let diff_ns = now_ns - ts_ns; let diff_secs = diff_ns / 1_000_000_000; if diff_secs < 60 { format!("{}s ago", diff_secs) } else if diff_secs < 3600 { format!("{}m ago", diff_secs / 60) } else { format!("{}h ago", diff_secs / 3600) } } // Helper: suggest a fix based on error fn suggest_fix(record: &TraceRecord, error_body: Option<&str>) -> Option<String> { let status = record.status(); let path = record.path(); // Parse error body for common patterns if let Some(body) = error_body { if body.contains("ParseError") || body.contains("validation") { return Some("Check request body schema matches expected format".to_string()); } if body.contains("timeout") { return Some("Upstream service timed out - check service health".to_string()); } if body.contains("ECONNREFUSED") { return Some("Upstream service not running - start the service".to_string()); } if body.contains("token") || body.contains("auth") || body.contains("unauthorized") { return Some("Authentication failed - check credentials/token".to_string()); } } // Fallback suggestions based on status code match status { 400 => Some("Bad request - check request parameters".to_string()), 401 => Some("Unauthorized - check authentication".to_string()), 403 => Some("Forbidden - check permissions".to_string()), 404 => Some(format!("Not found - verify endpoint '{}' exists", path)), 500 => Some("Internal server error - check server logs".to_string()), 502 => Some("Bad gateway - upstream service may be down".to_string()), 503 => Some("Service unavailable - service may be overloaded".to_string()), 504 => Some("Gateway timeout - upstream service too slow".to_string()), _ => None, } } /// Background task that periodically updates the summary file pub struct SummaryWriter { buffer: Arc<TraceBuffer>, state: Arc<SummaryState>, path: PathBuf, interval: Duration, } impl SummaryWriter { pub fn new( buffer: Arc<TraceBuffer>, state: Arc<SummaryState>, path: PathBuf, interval: Duration, ) -> Self { Self { buffer, state, path, interval, } } /// Run the summary writer (blocking) pub fn run(&self) { loop { let summary = compute_summary(&self.buffer, &self.state); if let Err(e) = write_summary(&summary, &self.path) { eprintln!("Failed to write trace summary: {}", e); } std::thread::sleep(self.interval); } } /// Spawn as a background thread pub fn spawn(self) -> std::thread::JoinHandle<()> { std::thread::spawn(move || self.run()) } } ================================================ FILE: src/proxy/trace.rs ================================================ //! Zero-cost request tracing via mmap ring buffer. //! //! Inspired by fishx's observe.rs - uses mmap + atomic index for lock-free, //! allocation-free request recording. use std::fs::OpenOptions; use std::os::fd::AsRawFd; use std::os::unix::fs::OpenOptionsExt; use std::path::PathBuf; use std::ptr::{null_mut, write_unaligned}; use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use libc::{CLOCK_MONOTONIC, MAP_SHARED, PROT_READ, PROT_WRITE}; // Magic bytes to identify trace files const TRACE_MAGIC: &[u8; 8] = b"PROXYTRC"; const TRACE_VERSION: u32 = 1; // Record layout - 128 bytes per request const TRACE_PATH_BYTES: usize = 64; const TRACE_RECORD_SIZE: usize = 128; const TRACE_HEADER_SIZE: usize = 64; const TRACE_DEFAULT_SIZE: usize = 16 * 1024 * 1024; // 16MB default // Field indices in the record (as u64 words) const IDX_TS_NS: usize = 0; const IDX_REQ_ID: usize = 1; const IDX_LATENCY_STATUS: usize = 2; // latency_us (32) | status (16) | method (8) | flags (8) const IDX_BYTES: usize = 3; // bytes_in (32) | bytes_out (32) const IDX_TARGET_PATH_LEN: usize = 4; // target_idx (8) | path_len (8) | trace_id_high (48) const IDX_TRACE_ID_LOW: usize = 5; const IDX_PATH_HASH: usize = 6; const IDX_UPSTREAM_LATENCY: usize = 7; // upstream_latency_us (32) | reserved (32) // Remaining 64 bytes = path prefix /// HTTP methods encoded as u8 #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Method { Unknown = 0, Get = 1, Post = 2, Put = 3, Delete = 4, Patch = 5, Head = 6, Options = 7, Connect = 8, Trace = 9, } impl From<&str> for Method { fn from(s: &str) -> Self { match s { "GET" => Method::Get, "POST" => Method::Post, "PUT" => Method::Put, "DELETE" => Method::Delete, "PATCH" => Method::Patch, "HEAD" => Method::Head, "OPTIONS" => Method::Options, "CONNECT" => Method::Connect, "TRACE" => Method::Trace, _ if s.eq_ignore_ascii_case("GET") => Method::Get, _ if s.eq_ignore_ascii_case("POST") => Method::Post, _ if s.eq_ignore_ascii_case("PUT") => Method::Put, _ if s.eq_ignore_ascii_case("DELETE") => Method::Delete, _ if s.eq_ignore_ascii_case("PATCH") => Method::Patch, _ if s.eq_ignore_ascii_case("HEAD") => Method::Head, _ if s.eq_ignore_ascii_case("OPTIONS") => Method::Options, _ if s.eq_ignore_ascii_case("CONNECT") => Method::Connect, _ if s.eq_ignore_ascii_case("TRACE") => Method::Trace, _ => Method::Unknown, } } } /// Trace record header (64 bytes, at start of mmap file) #[repr(C)] struct TraceHeader { magic: [u8; 8], version: u32, record_size: u32, capacity: u64, write_index: AtomicU64, req_counter: AtomicU64, // Target names stored after header, before records target_count: u32, _reserved: [u8; 20], } /// A single trace record (128 bytes) #[repr(C)] #[derive(Clone, Copy)] pub struct TraceRecord { words: [u64; 8], path: [u8; TRACE_PATH_BYTES], } impl TraceRecord { pub fn new() -> Self { Self { words: [0; 8], path: [0; TRACE_PATH_BYTES], } } #[inline] pub fn set_timestamp(&mut self, ts_ns: u64) { self.words[IDX_TS_NS] = ts_ns; } #[inline] pub fn set_req_id(&mut self, req_id: u64) { self.words[IDX_REQ_ID] = req_id; } #[inline] pub fn set_latency_status(&mut self, latency_us: u32, status: u16, method: Method, flags: u8) { self.words[IDX_LATENCY_STATUS] = (latency_us as u64) << 32 | (status as u64) << 16 | (method as u64) << 8 | flags as u64; } #[inline] pub fn set_bytes(&mut self, bytes_in: u32, bytes_out: u32) { self.words[IDX_BYTES] = (bytes_in as u64) << 32 | bytes_out as u64; } #[inline] pub fn set_target_and_trace_id(&mut self, target_idx: u8, path_len: u8, trace_id: u128) { let trace_id_high = (trace_id >> 64) as u64; let trace_id_low = trace_id as u64; self.words[IDX_TARGET_PATH_LEN] = (target_idx as u64) << 56 | (path_len as u64) << 48 | (trace_id_high & 0xFFFF_FFFF_FFFF); self.words[IDX_TRACE_ID_LOW] = trace_id_low; } #[inline] pub fn set_path_hash(&mut self, hash: u64) { self.words[IDX_PATH_HASH] = hash; } #[inline] pub fn set_upstream_latency(&mut self, upstream_latency_us: u32) { self.words[IDX_UPSTREAM_LATENCY] = (upstream_latency_us as u64) << 32; } #[inline] pub fn set_path(&mut self, path: &str) { let bytes = path.as_bytes(); let len = bytes.len().min(TRACE_PATH_BYTES); self.path[..len].copy_from_slice(&bytes[..len]); } // Getters for reading records #[inline] pub fn timestamp(&self) -> u64 { self.words[IDX_TS_NS] } #[inline] pub fn req_id(&self) -> u64 { self.words[IDX_REQ_ID] } #[inline] pub fn latency_us(&self) -> u32 { (self.words[IDX_LATENCY_STATUS] >> 32) as u32 } #[inline] pub fn status(&self) -> u16 { ((self.words[IDX_LATENCY_STATUS] >> 16) & 0xFFFF) as u16 } #[inline] pub fn method(&self) -> Method { match ((self.words[IDX_LATENCY_STATUS] >> 8) & 0xFF) as u8 { 1 => Method::Get, 2 => Method::Post, 3 => Method::Put, 4 => Method::Delete, 5 => Method::Patch, 6 => Method::Head, 7 => Method::Options, 8 => Method::Connect, 9 => Method::Trace, _ => Method::Unknown, } } #[inline] pub fn flags(&self) -> u8 { (self.words[IDX_LATENCY_STATUS] & 0xFF) as u8 } #[inline] pub fn bytes_in(&self) -> u32 { (self.words[IDX_BYTES] >> 32) as u32 } #[inline] pub fn bytes_out(&self) -> u32 { (self.words[IDX_BYTES] & 0xFFFF_FFFF) as u32 } #[inline] pub fn target_idx(&self) -> u8 { (self.words[IDX_TARGET_PATH_LEN] >> 56) as u8 } #[inline] pub fn path_len(&self) -> u8 { ((self.words[IDX_TARGET_PATH_LEN] >> 48) & 0xFF) as u8 } #[inline] pub fn trace_id(&self) -> u128 { let high = (self.words[IDX_TARGET_PATH_LEN] & 0xFFFF_FFFF_FFFF) as u128; let low = self.words[IDX_TRACE_ID_LOW] as u128; (high << 64) | low } #[inline] pub fn path_hash(&self) -> u64 { self.words[IDX_PATH_HASH] } #[inline] pub fn upstream_latency_us(&self) -> u32 { (self.words[IDX_UPSTREAM_LATENCY] >> 32) as u32 } #[inline] pub fn path(&self) -> &str { let len = self.path_len() as usize; std::str::from_utf8(&self.path[..len.min(TRACE_PATH_BYTES)]).unwrap_or("") } /// Check if this is an error response #[inline] pub fn is_error(&self) -> bool { self.status() >= 400 } /// Check if this is a slow request (> threshold_ms) #[inline] pub fn is_slow(&self, threshold_ms: u32) -> bool { self.latency_us() > threshold_ms * 1000 } } impl Default for TraceRecord { fn default() -> Self { Self::new() } } /// The trace buffer state (mmap handle) pub struct TraceBuffer { _map: *mut u8, _map_len: usize, header: *mut TraceHeader, records: *mut u8, capacity: u64, start_time: Instant, } // Safety: The mmap is process-local and we use atomic operations unsafe impl Send for TraceBuffer {} unsafe impl Sync for TraceBuffer {} impl TraceBuffer { /// Initialize a new trace buffer at the given path pub fn init(dir: &PathBuf, size: usize) -> Option<Self> { if std::fs::create_dir_all(dir).is_err() { return None; } let pid = unsafe { libc::getpid() }; let filename = format!("trace.{}.bin", pid); let path = dir.join(filename); let file = OpenOptions::new() .create(true) .read(true) .write(true) .mode(0o600) .open(&path) .ok()?; // Ensure file is the right size if set_file_len(&file, size).is_err() { return None; } let map = unsafe { libc::mmap( null_mut(), size, PROT_READ | PROT_WRITE, MAP_SHARED, file.as_raw_fd(), 0, ) }; if map == libc::MAP_FAILED { return None; } let header = map as *mut TraceHeader; let records = unsafe { (map as *mut u8).add(TRACE_HEADER_SIZE) }; let capacity = ((size - TRACE_HEADER_SIZE) / TRACE_RECORD_SIZE) as u64; if capacity == 0 { unsafe { libc::munmap(map, size); } return None; } // Initialize or validate header unsafe { if (*header).magic != *TRACE_MAGIC || (*header).version != TRACE_VERSION || (*header).record_size != TRACE_RECORD_SIZE as u32 || (*header).capacity != capacity { write_unaligned( header, TraceHeader { magic: *TRACE_MAGIC, version: TRACE_VERSION, record_size: TRACE_RECORD_SIZE as u32, capacity, write_index: AtomicU64::new(0), req_counter: AtomicU64::new(0), target_count: 0, _reserved: [0; 20], }, ); } } Some(TraceBuffer { _map: map as *mut u8, _map_len: size, header, records, capacity, start_time: Instant::now(), }) } /// Record a completed request (zero allocations) #[inline] pub fn record(&self, record: &TraceRecord) { let idx = unsafe { (*self.header).write_index.fetch_add(1, Ordering::Relaxed) }; let slot = (idx % self.capacity) as usize; let dst = unsafe { self.records.add(slot * TRACE_RECORD_SIZE) as *mut TraceRecord }; unsafe { write_unaligned(dst, *record) }; } /// Get the next request ID (monotonically increasing) #[inline] pub fn next_req_id(&self) -> u64 { unsafe { (*self.header).req_counter.fetch_add(1, Ordering::Relaxed) } } /// Get current write index #[inline] pub fn write_index(&self) -> u64 { unsafe { (*self.header).write_index.load(Ordering::Relaxed) } } /// Get capacity (number of records) #[inline] pub fn capacity(&self) -> u64 { self.capacity } /// Read a record at a given index (wraps around) #[inline] pub fn read(&self, idx: u64) -> TraceRecord { let slot = (idx % self.capacity) as usize; let src = unsafe { self.records.add(slot * TRACE_RECORD_SIZE) as *const TraceRecord }; unsafe { std::ptr::read_unaligned(src) } } /// Iterate over recent records (most recent first) pub fn recent(&self, count: usize) -> Vec<TraceRecord> { let write_idx = self.write_index(); let count = count.min(write_idx as usize).min(self.capacity as usize); let mut records = Vec::with_capacity(count); for i in 0..count { let idx = write_idx.saturating_sub(1 + i as u64); records.push(self.read(idx)); } records } /// Iterate over records matching a predicate pub fn filter<F>(&self, count: usize, predicate: F) -> Vec<TraceRecord> where F: Fn(&TraceRecord) -> bool, { let write_idx = self.write_index(); let max_scan = (self.capacity as usize).min(write_idx as usize); let mut records = Vec::new(); for i in 0..max_scan { if records.len() >= count { break; } let idx = write_idx.saturating_sub(1 + i as u64); let record = self.read(idx); if predicate(&record) { records.push(record); } } records } /// Get timestamp of buffer creation pub fn start_time(&self) -> Instant { self.start_time } } impl Drop for TraceBuffer { fn drop(&mut self) { unsafe { libc::munmap(self._map as *mut libc::c_void, self._map_len); } } } /// Global trace buffer (lazily initialized) static TRACE_BUFFER: OnceLock<Option<TraceBuffer>> = OnceLock::new(); /// Initialize the global trace buffer pub fn init_global(dir: PathBuf, size: usize) -> bool { TRACE_BUFFER .get_or_init(|| TraceBuffer::init(&dir, size)) .is_some() } /// Get the global trace buffer pub fn global() -> Option<&'static TraceBuffer> { TRACE_BUFFER.get().and_then(|b| b.as_ref()) } /// Record a request to the global buffer #[inline] pub fn record(record: &TraceRecord) { if let Some(buf) = global() { buf.record(record); } } /// Get next request ID from global buffer #[inline] pub fn next_req_id() -> u64 { global().map(|b| b.next_req_id()).unwrap_or(0) } // Helper: get monotonic time in nanoseconds pub fn now_ns() -> u64 { unsafe { let mut ts = std::mem::MaybeUninit::<libc::timespec>::uninit(); if libc::clock_gettime(CLOCK_MONOTONIC, ts.as_mut_ptr()) == 0 { let ts = ts.assume_init(); return (ts.tv_sec as u64) .saturating_mul(1_000_000_000) .saturating_add(ts.tv_nsec as u64); } } 0 } // Helper: FNV-1a hash for path strings pub fn hash_path(path: &str) -> u64 { let mut hash: u64 = 0xcbf29ce484222325; for b in path.bytes() { hash ^= b as u64; hash = hash.wrapping_mul(0x100000001b3); } hash } // Helper: set file length fn set_file_len(file: &std::fs::File, size: usize) -> std::io::Result<()> { let fd = file.as_raw_fd(); let res = unsafe { libc::ftruncate(fd, size as libc::off_t) }; if res == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } } /// Get the default trace directory pub fn default_trace_dir() -> PathBuf { dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("flow") .join("proxy") } /// Get the default trace size pub fn default_trace_size() -> usize { TRACE_DEFAULT_SIZE } #[cfg(test)] mod tests { use super::*; #[test] fn test_record_roundtrip() { let mut record = TraceRecord::new(); record.set_timestamp(12345678); record.set_req_id(42); record.set_latency_status(1500, 200, Method::Get, 0); record.set_bytes(100, 2048); record.set_target_and_trace_id(1, 10, 0xDEADBEEF); record.set_path_hash(hash_path("/api/users")); record.set_upstream_latency(1200); record.set_path("/api/users"); assert_eq!(record.timestamp(), 12345678); assert_eq!(record.req_id(), 42); assert_eq!(record.latency_us(), 1500); assert_eq!(record.status(), 200); assert_eq!(record.method(), Method::Get); assert_eq!(record.bytes_in(), 100); assert_eq!(record.bytes_out(), 2048); assert_eq!(record.target_idx(), 1); assert_eq!(record.upstream_latency_us(), 1200); assert_eq!(record.path(), "/api/users"); } } ================================================ FILE: src/publish.rs ================================================ //! Publish projects to gitedit.dev or GitHub. use std::collections::HashSet; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use anyhow::{Context, Result, bail}; use crossterm::event::{self, Event as CEvent, KeyCode}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use reqwest::blocking::Client; use serde::Serialize; use crate::cli::{PublishAction, PublishCommand, PublishOpts}; use crate::config; use crate::vcs; fn parse_github_repo(url: &str) -> Result<(String, String, String)> { let trimmed = url.trim().trim_end_matches('/'); if trimmed.is_empty() { bail!("GitHub URL is empty"); } if let Some(rest) = trimmed.strip_prefix("git@github.com:") { let rest = rest.trim_end_matches(".git"); let Some((owner, repo)) = rest.split_once('/') else { bail!("Invalid GitHub SSH URL: {}", url); }; return Ok(( owner.to_string(), repo.to_string(), format!("git@github.com:{}/{}.git", owner, repo), )); } if let Some(rest) = trimmed.strip_prefix("https://github.com/") { let rest = rest.trim_end_matches(".git"); let Some((owner, repo)) = rest.split_once('/') else { bail!("Invalid GitHub HTTPS URL: {}", url); }; return Ok(( owner.to_string(), repo.to_string(), format!("git@github.com:{}/{}.git", owner, repo), )); } bail!( "Unsupported GitHub URL (expected https://github.com/... or git@github.com:...): {}", url ); } /// Run the publish command. pub fn run(cmd: PublishCommand) -> Result<()> { match cmd.action { Some(PublishAction::Gitedit(opts)) => run_gitedit(opts), Some(PublishAction::Github(opts)) => run_github(opts), None => run_fuzzy_select(), } } /// Show fuzzy picker for publish targets. fn run_fuzzy_select() -> Result<()> { let options = vec![ ("gitedit", "Publish to gitedit.dev"), ("github", "Publish to GitHub"), ]; let input = options .iter() .map(|(cmd, desc)| format!("{}\t{}", cmd, desc)) .collect::<Vec<_>>() .join("\n"); let output = Command::new("fzf") .args([ "--height=10", "--reverse", "--delimiter=\t", "--with-nth=1,2", ]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() .context("failed to spawn fzf")?; output.stdin.as_ref().unwrap().write_all(input.as_bytes())?; let result = output.wait_with_output()?; if !result.status.success() { return Ok(()); // User cancelled } let selected = String::from_utf8_lossy(&result.stdout) .trim() .split('\t') .next() .unwrap_or("") .to_string(); match selected.as_str() { "gitedit" => run_gitedit(PublishOpts::default()), "github" => run_github(PublishOpts::default()), _ => Ok(()), } } /// Run the GitHub publish flow. pub fn run_github(opts: PublishOpts) -> Result<()> { // Check if gh CLI is available if Command::new("gh").arg("--version").output().is_err() { bail!("GitHub CLI (gh) is not installed. Install from: https://cli.github.com"); } // Check if authenticated let auth_status = Command::new("gh") .args(["auth", "status"]) .output() .context("failed to check gh auth status")?; if !auth_status.status.success() { println!("Not authenticated with GitHub."); println!("Run: gh auth login"); bail!("GitHub authentication required"); } // Get current directory name as default repo name let cwd = std::env::current_dir()?; let folder_name = cwd .file_name() .and_then(|n| n.to_str()) .unwrap_or("repo") .to_string(); // Check if already a git repo let is_git_repo = cwd.join(".git").exists(); // Get GitHub username (fallback owner) let gh_user = Command::new("gh") .args(["api", "user", "-q", ".login"]) .output() .context("failed to get GitHub username")?; let username = String::from_utf8_lossy(&gh_user.stdout).trim().to_string(); if username.is_empty() { bail!("Could not determine GitHub username"); } let mut owner = opts.owner.clone().unwrap_or_else(|| username.clone()); let mut repo_name_from_url: Option<String> = None; let mut remote_from_url: Option<String> = None; if let Some(url) = opts.url.as_ref() { let (parsed_owner, parsed_name, parsed_remote) = parse_github_repo(url)?; owner = parsed_owner; repo_name_from_url = Some(parsed_name); remote_from_url = Some(parsed_remote); } // Determine repo name let repo_name = if let Some(name) = opts.name { name } else if let Some(name) = repo_name_from_url.clone() { name } else if opts.yes { folder_name.clone() } else { print!("Repository name [{}]: ", folder_name); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim(); if input.is_empty() { folder_name.clone() } else { input.to_string() } }; // Determine visibility let is_public = if opts.public { true } else if opts.private { false } else if opts.yes { false // Default to private if -y is passed } else { prompt_public_choice()? }; let visibility = if is_public { "public" } else { "private" }; let full_name = format!("{}/{}", owner, repo_name); let desired_remote = remote_from_url.unwrap_or_else(|| format!("git@github.com:{}.git", full_name)); let set_origin = opts.set_origin || opts.url.is_some(); // Check if repo already exists let repo_check = Command::new("gh") .args([ "repo", "view", &full_name, "--json", "visibility", "-q", ".visibility", ]) .output(); if let Ok(output) = repo_check { if output.status.success() { let current_visibility = String::from_utf8_lossy(&output.stdout) .trim() .to_lowercase(); println!( "Repository {} already exists ({}).", full_name, current_visibility ); // Check if visibility needs to change let target_visibility = if is_public { "public" } else { "private" }; if current_visibility != target_visibility { println!("Updating visibility to {}...", target_visibility); let visibility_flag = format!("--visibility={}", target_visibility); let update_result = Command::new("gh") .args([ "repo", "edit", &full_name, &visibility_flag, "--accept-visibility-change-consequences", ]) .status() .context("failed to update repository visibility")?; if update_result.success() { println!("✓ Updated to {}", target_visibility); } else { println!("Warning: Could not update visibility"); } } // Check if origin remote exists let origin_check = Command::new("git") .args(["remote", "get-url", "origin"]) .output(); if let Ok(output) = origin_check { if output.status.success() { let current_origin = String::from_utf8_lossy(&output.stdout).trim().to_string(); if set_origin && current_origin != desired_remote { println!("Updating origin remote..."); Command::new("git") .args(["remote", "set-url", "origin", &desired_remote]) .status() .context("failed to update origin remote")?; } let should_push = if opts.yes { true } else { prompt_push_choice()? }; if should_push { println!("Pushing to {}...", full_name); push_to_origin()?; } println!("\n✓ https://github.com/{}", full_name); return Ok(()); } } // Add origin and push println!("Adding origin remote..."); let remote_url = desired_remote.clone(); Command::new("git") .args(["remote", "add", "origin", &remote_url]) .status() .context("failed to add origin remote")?; println!("Pushing to {}...", full_name); push_to_origin()?; println!("\n✓ Published to https://github.com/{}", full_name); return Ok(()); } } // Show confirmation if !opts.yes { println!(); println!("Create repository:"); println!(" Name: {}", full_name); println!(" Visibility: {}", visibility); if let Some(ref desc) = opts.description { println!(" Description: {}", desc); } println!(); print!("Proceed? [Y/n]: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim().to_lowercase(); if input == "n" || input == "no" { println!("Aborted."); return Ok(()); } } // Initialize git if needed if !is_git_repo { println!("Initializing git repository..."); Command::new("git") .args(["init"]) .status() .context("failed to initialize git")?; // Create initial commit if no commits exist let has_commits = Command::new("git") .args(["rev-parse", "HEAD"]) .output() .map(|o| o.status.success()) .unwrap_or(false); if !has_commits { // Stage all files Command::new("git") .args(["add", "."]) .status() .context("failed to stage files")?; Command::new("git") .args(["commit", "-m", "Initial commit"]) .status() .context("failed to create initial commit")?; } } // Create the repository println!("Creating repository on GitHub..."); let mut args = vec![ "repo".to_string(), "create".to_string(), repo_name.clone(), format!("--{}", visibility), "--source=.".to_string(), "--push".to_string(), ]; if let Some(desc) = opts.description { args.push("--description".to_string()); args.push(desc); } let create_result = Command::new("gh") .args(&args) .status() .context("failed to create repository")?; if !create_result.success() { bail!("Failed to create repository"); } println!(); println!("✓ Published to https://github.com/{}", full_name); Ok(()) } const MAX_GITEDIT_FILE_BYTES: u64 = 512 * 1024; const MAX_GITEDIT_TOTAL_BYTES: u64 = 8 * 1024 * 1024; const MAX_GITEDIT_FILES: usize = 4000; #[derive(Serialize)] #[serde(rename_all = "snake_case")] struct RepoSnapshot { repo: RepoMeta, tree: Vec<RepoTreeEntry>, files: Vec<RepoFileEntry>, readme: Option<RepoReadme>, } #[derive(Serialize)] #[serde(rename_all = "snake_case")] struct RepoMeta { description: Option<String>, default_branch: String, language: Option<String>, } #[derive(Serialize)] #[serde(rename_all = "snake_case")] struct RepoTreeEntry { path: String, #[serde(rename = "type")] entry_type: String, sha: String, size: Option<u64>, } #[derive(Serialize)] #[serde(rename_all = "snake_case")] struct RepoFileEntry { path: String, content: String, size: u64, is_binary: bool, encoding: String, } #[derive(Serialize)] #[serde(rename_all = "snake_case")] struct RepoReadme { path: String, content: String, } #[derive(Serialize)] #[serde(rename_all = "snake_case")] struct GiteditSyncPayload { owner: String, repo: String, commit_sha: String, branch: Option<String>, #[serde(rename = "ref")] ref_name: Option<String>, event: String, source: String, commit_message: Option<String>, author_name: Option<String>, author_email: Option<String>, session_hash: Option<String>, repo_snapshot: Option<RepoSnapshot>, } fn run_gitedit(opts: PublishOpts) -> Result<()> { let repo_root = git_root()?; ensure_git_repo(&repo_root)?; let folder_name = repo_root .file_name() .and_then(|n| n.to_str()) .unwrap_or("repo") .to_string(); let repo_name = resolve_repo_name(&opts, &folder_name)?; let (owner, repo_override) = gitedit_repo_override(&repo_root); let repo_name = repo_override.unwrap_or(repo_name); let owner = resolve_gitedit_owner(&opts, owner, &repo_root)?; let full_name = format!("{}/{}", owner, repo_name); if !opts.yes { println!(); println!("Publish to gitedit.dev:"); println!(" Repo: {}", full_name); if let Some(ref desc) = opts.description { println!(" Description: {}", desc); } println!(); print!("Proceed? [Y/n]: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim().to_ascii_lowercase(); if input == "n" || input == "no" { println!("Aborted."); return Ok(()); } } let commit_sha = git_capture_in(&repo_root, &["rev-parse", "HEAD"]) .context("failed to read git HEAD")? .trim() .to_string(); let branch = git_capture_in(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .ok() .map(|value| value.trim().to_string()) .filter(|value| value != "HEAD"); let ref_name = branch.as_ref().map(|name| format!("refs/heads/{}", name)); let default_branch = branch.clone().unwrap_or_else(|| "main".to_string()); let commit_message = git_capture_in(&repo_root, &["log", "-1", "--format=%B"]) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let author_name = git_capture_in(&repo_root, &["log", "-1", "--format=%an"]) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let author_email = git_capture_in(&repo_root, &["log", "-1", "--format=%ae"]) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let snapshot = build_repo_snapshot(&repo_root, &default_branch, opts.description.clone())?; let payload = GiteditSyncPayload { owner: owner.clone(), repo: repo_name.clone(), commit_sha, branch, ref_name, event: "commit".to_string(), source: "flow-cli".to_string(), commit_message, author_name, author_email, session_hash: None, repo_snapshot: Some(snapshot), }; let base_url = gitedit_api_url(&repo_root); let api_url = format!("{}/api/mirrors/sync", base_url.trim_end_matches('/')); let view_url = format!("{}/{}/{}", base_url.trim_end_matches('/'), owner, repo_name); let token = gitedit_token(&repo_root); let client = Client::builder() .timeout(Duration::from_secs(30)) .build() .context("failed to build HTTP client")?; let mut request = client.post(&api_url).json(&payload); if let Some(token) = token { request = request.bearer_auth(token); } let response = request.send().context("failed to publish to gitedit")?; if !response.status().is_success() { bail!("gitedit publish failed: HTTP {}", response.status()); } println!(); println!("✓ Published to {}", view_url); Ok(()) } fn resolve_repo_name(opts: &PublishOpts, fallback: &str) -> Result<String> { let name = if let Some(name) = opts.name.clone() { name } else if opts.yes { fallback.to_string() } else { print!("Repository name [{}]: ", fallback); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim(); if input.is_empty() { fallback.to_string() } else { input.to_string() } }; Ok(name) } fn resolve_gitedit_owner( opts: &PublishOpts, override_owner: Option<String>, _repo_root: &Path, ) -> Result<String> { if let Some(owner) = opts.owner.clone() { return Ok(owner); } if let Some(owner) = override_owner { return Ok(owner); } if let Ok(owner) = std::env::var("GITEDIT_OWNER") { let owner = owner.trim(); if !owner.is_empty() { return Ok(owner.to_string()); } } if let Ok(owner) = std::env::var("USER") { let slug = sanitize_slug(&owner); if !slug.is_empty() { if opts.yes { return Ok(slug); } print!("gitedit owner [{}]: ", slug); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim(); if input.is_empty() { return Ok(slug); } return Ok(input.to_string()); } } if opts.yes { bail!("gitedit owner not set (use --owner or GITEDIT_OWNER)"); } print!("gitedit owner: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim(); if input.is_empty() { bail!("gitedit owner is required"); } Ok(input.to_string()) } fn gitedit_repo_override(repo_root: &Path) -> (Option<String>, Option<String>) { let flow_path = find_flow_toml(repo_root); let Some(flow_path) = flow_path else { return (None, None); }; let cfg = match config::load(&flow_path) { Ok(cfg) => cfg, Err(_) => return (None, None), }; let raw = match cfg.options.gitedit_repo_full_name { Some(value) => value, None => return (None, None), }; let mut parts = raw.split('/'); let owner = parts.next().map(|value| value.to_string()); let repo = parts.next().map(|value| value.to_string()); (owner, repo) } fn gitedit_api_url(repo_root: &Path) -> String { let flow_path = find_flow_toml(repo_root); if let Some(flow_path) = flow_path { if let Ok(cfg) = config::load(&flow_path) { if let Some(url) = cfg.options.gitedit_url { let trimmed = url.trim().to_string(); if !trimmed.is_empty() { return trimmed; } } } } "https://gitedit.dev".to_string() } fn gitedit_token(repo_root: &Path) -> Option<String> { for key in [ "GITEDIT_PUBLISH_TOKEN", "GITEDIT_TOKEN", "FLOW_GITEDIT_TOKEN", ] { if let Ok(value) = std::env::var(key) { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } let flow_path = find_flow_toml(repo_root)?; let cfg = config::load(&flow_path).ok()?; cfg.options.gitedit_token } fn find_flow_toml(start: &Path) -> Option<PathBuf> { let mut current = start.to_path_buf(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } fn git_root() -> Result<PathBuf> { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output() .context("failed to locate git root")?; if !output.status.success() { bail!("not inside a git repository"); } let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); Ok(PathBuf::from(path)) } fn ensure_git_repo(repo_root: &Path) -> Result<()> { let _ = vcs::ensure_jj_repo_in(repo_root)?; let git_dir = repo_root.join(".git"); if !git_dir.exists() { Command::new("git") .args(["init"]) .current_dir(repo_root) .status() .context("failed to initialize git")?; } let has_commits = Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(repo_root) .output() .map(|o| o.status.success()) .unwrap_or(false); if !has_commits { Command::new("git") .args(["add", "."]) .current_dir(repo_root) .status() .context("failed to stage files")?; Command::new("git") .args(["commit", "-m", "Initial commit"]) .current_dir(repo_root) .status() .context("failed to create initial commit")?; } Ok(()) } fn git_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .args(args) .current_dir(repo_root) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { bail!("git {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn build_repo_snapshot( repo_root: &Path, default_branch: &str, description: Option<String>, ) -> Result<RepoSnapshot> { let tree_output = git_capture_in(repo_root, &["ls-tree", "-r", "-t", "-l", "HEAD"])?; let mut tree = Vec::new(); let mut files = Vec::new(); let mut seen_paths = HashSet::new(); let mut total_bytes: u64 = 0; let mut skipped_files: usize = 0; let mut readme_path: Option<String> = None; for line in tree_output.lines() { let Some((left, path)) = line.split_once('\t') else { continue; }; let mut parts = left.split_whitespace(); let _mode = parts.next(); let entry_type = match parts.next() { Some(value) => value, None => continue, }; let sha = match parts.next() { Some(value) => value.to_string(), None => continue, }; let size = parts.next().and_then(|value| value.parse::<u64>().ok()); let path = path.trim().to_string(); if path.is_empty() { continue; } if !seen_paths.insert(path.clone()) { continue; } tree.push(RepoTreeEntry { path: path.clone(), entry_type: entry_type.to_string(), sha: sha.clone(), size, }); if entry_type == "blob" { if files.len() >= MAX_GITEDIT_FILES { skipped_files += 1; continue; } let size_value = size.unwrap_or(0); let (content, is_binary, encoding, included_bytes) = read_blob_content(repo_root, &sha, size_value)?; if !content.is_empty() { total_bytes = total_bytes.saturating_add(included_bytes); } if total_bytes > MAX_GITEDIT_TOTAL_BYTES { files.push(RepoFileEntry { path: path.clone(), content: String::new(), size: size_value, is_binary: true, encoding: "binary".to_string(), }); skipped_files += 1; continue; } files.push(RepoFileEntry { path: path.clone(), content, size: size_value, is_binary, encoding, }); if readme_path.is_none() && is_readme_path(&path) { readme_path = Some(path); } } } let readme = readme_path.and_then(|path| { files .iter() .find(|entry| entry.path == path && !entry.is_binary) .map(|entry| RepoReadme { path: entry.path.clone(), content: entry.content.clone(), }) }); if skipped_files > 0 { println!( "Warning: skipped {} file(s) (size or limit exceeded) for gitedit snapshot.", skipped_files ); } Ok(RepoSnapshot { repo: RepoMeta { description, default_branch: default_branch.to_string(), language: None, }, tree, files, readme, }) } fn read_blob_content( repo_root: &Path, sha: &str, size: u64, ) -> Result<(String, bool, String, u64)> { if size > MAX_GITEDIT_FILE_BYTES { return Ok((String::new(), true, "binary".to_string(), 0)); } let output = Command::new("git") .args(["cat-file", "-p", sha]) .current_dir(repo_root) .output() .context("failed to read git blob")?; if !output.status.success() { return Ok((String::new(), true, "binary".to_string(), 0)); } if output.stdout.iter().any(|byte| *byte == 0) { return Ok((String::new(), true, "binary".to_string(), 0)); } match String::from_utf8(output.stdout) { Ok(text) => Ok((text, false, "utf-8".to_string(), size)), Err(_) => Ok((String::new(), true, "binary".to_string(), 0)), } } fn is_readme_path(path: &str) -> bool { let lower = path.to_ascii_lowercase(); lower.ends_with("readme.md") || lower.ends_with("readme.markdown") || lower.ends_with("readme.mdx") } fn sanitize_slug(value: &str) -> String { let mut out = String::new(); let mut prev_dash = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); prev_dash = false; } else if ch == '-' || ch == '_' { out.push(ch); prev_dash = ch == '-'; } else if ch.is_whitespace() || ch == '.' || ch == '/' { if !prev_dash && !out.is_empty() { out.push('-'); prev_dash = true; } } } while out.ends_with('-') { out.pop(); } out } fn prompt_public_choice() -> Result<bool> { let default_public = false; print!("Public? [y/N]: "); io::stdout().flush()?; if io::stdin().is_terminal() { return read_yes_no_key(default_public); } let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_public); } Ok(matches!( answer.as_str(), "y" | "yes" | "public" | "pub" | "p" )) } fn prompt_push_choice() -> Result<bool> { let default_push = true; print!("Push current branch to origin? [Y/n]: "); io::stdout().flush()?; if io::stdin().is_terminal() { return read_yes_no_key(default_push); } let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_push); } Ok(matches!(answer.as_str(), "y" | "yes")) } fn read_yes_no_key(default_yes: bool) -> Result<bool> { enable_raw_mode().context("failed to enable raw mode")?; let mut selection = default_yes; let mut echo_char: Option<char> = None; loop { if let CEvent::Key(key) = event::read()? { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { selection = true; echo_char = Some('y'); break; } KeyCode::Char('n') | KeyCode::Char('N') => { selection = false; echo_char = Some('n'); break; } KeyCode::Enter => { break; } KeyCode::Esc => { selection = false; break; } _ => {} } } } disable_raw_mode().context("failed to disable raw mode")?; if let Some(ch) = echo_char { println!("{ch}"); } else { println!(); } Ok(selection) } fn push_to_origin() -> Result<()> { // Get current branch let branch = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .output() .context("failed to get current branch")?; let branch = String::from_utf8_lossy(&branch.stdout).trim().to_string(); let branch = if branch.is_empty() || branch == "HEAD" { "main".to_string() } else { branch }; let status = Command::new("git") .args(["push", "-u", "origin", &branch]) .status() .context("failed to push to origin")?; if !status.success() { bail!("git push failed"); } Ok(()) } ================================================ FILE: src/push.rs ================================================ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use crate::cli::PushCommand; use crate::{env, ssh, ssh_keys}; pub fn run(cmd: PushCommand) -> Result<()> { let repo_root = git_root()?; let current_branch = current_branch(&repo_root)?; let upstream_url = git_capture_in(&repo_root, &["remote", "get-url", "upstream"]) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let origin_url = git_capture_in(&repo_root, &["remote", "get-url", "origin"]) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let owner = resolve_push_owner(cmd.owner.as_deref())?; let repo_name = if let Some(repo) = cmd.repo.as_deref() { repo.trim().to_string() } else { derive_repo_name(&repo_root, upstream_url.as_deref(), origin_url.as_deref())? }; if repo_name.is_empty() { bail!("could not determine repo name (use --repo)"); } let target_url = choose_github_remote_url(&owner, &repo_name, &cmd)?; if cmd.dry_run { println!("Repo: {}", repo_root.display()); println!("Branch: {}", current_branch); println!("Remote: {}", cmd.remote); println!("Target: {}", target_url); return Ok(()); } ensure_remote_points_to_target( &repo_root, &cmd.remote, &target_url, upstream_url.as_deref(), cmd.force, )?; if cmd.create_repo { ensure_github_repo_exists(&owner, &repo_name)?; } println!("==> Pushing {} to {}...", current_branch, cmd.remote); git_run_in(&repo_root, &["push", "-u", &cmd.remote, ¤t_branch])?; println!("✓ Pushed to {}/{}", owner, repo_name); Ok(()) } fn resolve_push_owner(cli: Option<&str>) -> Result<String> { if let Some(value) = cli { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(trimmed.to_string()); } } resolve_fork_owner(None) } /// Resolve the GitHub owner for fork push operations. /// /// Priority: explicit config → FLOW_PUSH_OWNER env → personal env → `gh api user` → `git config github.user`. pub(crate) fn resolve_fork_owner(config_owner: Option<&str>) -> Result<String> { if let Some(value) = config_owner { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(trimmed.to_string()); } } if let Ok(value) = std::env::var("FLOW_PUSH_OWNER") { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(trimmed.to_string()); } } if let Ok(Some(value)) = env::get_personal_env_var("FLOW_PUSH_OWNER") { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(trimmed.to_string()); } } // Try `gh api user` if let Ok(output) = Command::new("gh") .args(["api", "user", "-q", ".login"]) .stdin(Stdio::null()) .stderr(Stdio::null()) .output() { if output.status.success() { let login = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !login.is_empty() { return Ok(login); } } } // Try `git config github.user` if let Ok(output) = Command::new("git") .args(["config", "github.user"]) .stdin(Stdio::null()) .stderr(Stdio::null()) .output() { if output.status.success() { let user = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !user.is_empty() { return Ok(user); } } } bail!( "Could not determine GitHub owner. Configure it via:\n \ [git] fork-push-owner in flow.toml, or\n \ f env set FLOW_PUSH_OWNER=<owner> --personal, or\n \ gh auth login, or\n \ git config --global github.user <owner>" ); } pub(crate) fn derive_repo_name( repo_root: &Path, upstream_url: Option<&str>, origin_url: Option<&str>, ) -> Result<String> { if let Some(url) = upstream_url { if let Some((_owner, repo)) = parse_github_owner_repo(url) { return Ok(repo); } } if let Some(url) = origin_url { if let Some((_owner, repo)) = parse_github_owner_repo(url) { return Ok(repo); } } Ok(repo_root .file_name() .and_then(|s| s.to_str()) .unwrap_or("repo") .to_string()) } pub(crate) fn build_github_ssh_url(owner: &str, repo: &str) -> String { let owner = owner.trim(); let repo = repo.trim(); format!("git@github.com:{}/{}.git", owner, repo) } fn build_github_https_url(owner: &str, repo: &str) -> String { let owner = owner.trim(); let repo = repo.trim(); format!("https://github.com/{}/{}.git", owner, repo) } fn choose_github_remote_url(owner: &str, repo: &str, cmd: &PushCommand) -> Result<String> { let ssh_url = build_github_ssh_url(owner, repo); let https_url = build_github_https_url(owner, repo); match ssh::ssh_mode() { ssh::SshMode::Https => Ok(https_url), ssh::SshMode::Force => { if !cmd.no_ssh { if let Err(err) = ssh_keys::ensure_default_identity(cmd.ttl_hours) { eprintln!( "Warning: could not unlock Flow SSH key (continuing): {}", err ); } } Ok(ssh_url) } ssh::SshMode::Auto => { if !cmd.no_ssh { if let Err(err) = ssh_keys::ensure_default_identity(cmd.ttl_hours) { eprintln!( "Warning: could not unlock Flow SSH key (continuing): {}", err ); } } if ssh::has_identities() { Ok(ssh_url) } else { Ok(https_url) } } } } pub(crate) fn parse_github_owner_repo(url: &str) -> Option<(String, String)> { let trimmed = url.trim().trim_end_matches('/'); if trimmed.is_empty() { return None; } if let Some(rest) = trimmed.strip_prefix("git@github.com:") { let rest = rest.trim_end_matches(".git"); let (owner, repo) = rest.split_once('/')?; return Some((owner.to_string(), repo.to_string())); } if let Some(rest) = trimmed.strip_prefix("https://github.com/") { let rest = rest.trim_end_matches(".git"); let (owner, repo) = rest.split_once('/')?; return Some((owner.to_string(), repo.to_string())); } None } pub(crate) fn ensure_remote_points_to_target( repo_root: &Path, remote: &str, target_url: &str, upstream_url: Option<&str>, force: bool, ) -> Result<()> { let existing = git_capture_in(repo_root, &["remote", "get-url", remote]) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); if let Some(existing) = existing { if normalize_git_url(&existing) == normalize_git_url(target_url) { return Ok(()); } // Safe override when the remote points at upstream (read-only clone). let is_upstream = upstream_url .map(|u| normalize_git_url(u) == normalize_git_url(&existing)) .unwrap_or(false); if is_upstream || force { println!("==> Updating remote {} url...", remote); git_run_in(repo_root, &["remote", "set-url", remote, target_url])?; return Ok(()); } bail!( "remote '{}' already points to {}\nrefusing to overwrite without --force\n(target would be {})", remote, existing, target_url ); } println!("==> Adding remote {}...", remote); git_run_in(repo_root, &["remote", "add", remote, target_url])?; Ok(()) } pub(crate) fn ensure_github_repo_exists(owner: &str, repo: &str) -> Result<()> { let full_name = format!("{}/{}", owner.trim(), repo.trim()); let view = Command::new("gh") .args(["repo", "view", &full_name]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); if matches!(view, Ok(s) if s.success()) { return Ok(()); } println!("==> Creating GitHub repo {} (private)...", full_name); let status = Command::new("gh") .args(["repo", "create", &full_name, "--private", "--confirm"]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status(); match status { Ok(s) if s.success() => Ok(()), Ok(_) => bail!("failed to create repo via gh (is it installed/authenticated?)"), Err(err) => Err(err).context("failed to run gh"), } } pub(crate) fn normalize_git_url(url: &str) -> String { let url = url.trim(); let url = if url.starts_with("git@github.com:") { url.replace("git@github.com:", "github.com/") } else if url.starts_with("https://github.com/") { url.replace("https://github.com/", "github.com/") } else { url.to_string() }; url.trim_end_matches(".git").to_lowercase() } fn git_root() -> Result<PathBuf> { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output() .context("failed to locate git root")?; if !output.status.success() { bail!("not inside a git repository"); } let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); Ok(PathBuf::from(path)) } fn current_branch(repo_root: &Path) -> Result<String> { let output = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(repo_root) .output() .context("failed to read current branch")?; if !output.status.success() { bail!("git rev-parse --abbrev-ref HEAD failed"); } let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if name.is_empty() || name == "HEAD" { bail!("detached HEAD (checkout a branch first)"); } Ok(name) } pub(crate) fn git_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .args(args) .current_dir(repo_root) .output() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !output.status.success() { bail!("git {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } pub(crate) fn git_run_in(repo_root: &Path, args: &[&str]) -> Result<()> { let status = Command::new("git") .args(args) .current_dir(repo_root) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .with_context(|| format!("failed to run git {}", args.join(" ")))?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } ================================================ FILE: src/recipe.rs ================================================ use std::collections::BTreeSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use shellexpand::tilde; use crate::cli::{ RecipeAction, RecipeCommand, RecipeInitOpts, RecipeListOpts, RecipeRunOpts, RecipeScopeArg, RecipeSearchOpts, }; use crate::config; const ENV_GLOBAL_RECIPE_DIR: &str = "FLOW_RECIPES_GLOBAL_DIR"; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Scope { Project, Global, } impl Scope { fn as_str(self) -> &'static str { match self { Scope::Project => "project", Scope::Global => "global", } } } #[derive(Debug, Clone)] struct Recipe { id: String, name: String, description: String, path: PathBuf, scope: Scope, runner: RecipeRunner, tags: Vec<String>, } #[derive(Debug, Clone)] enum RecipeRunner { Shell { shell: String, command: String }, MoonbitFile, } #[derive(Debug, Clone, Default)] struct Frontmatter { title: Option<String>, description: Option<String>, tags: Vec<String>, } pub fn run(cmd: RecipeCommand) -> Result<()> { eprintln!( "warning: `f recipe` is legacy compatibility. Prefer task-centric workflows with flow.toml tasks + .ai/tasks/*.mbt." ); match cmd.action.unwrap_or(RecipeAction::List(RecipeListOpts { scope: RecipeScopeArg::All, query: None, global_dir: None, })) { RecipeAction::List(opts) => list_recipes(opts), RecipeAction::Search(opts) => search_recipes(opts), RecipeAction::Run(opts) => run_recipe(opts), RecipeAction::Init(opts) => init_recipes(opts), } } fn list_recipes(opts: RecipeListOpts) -> Result<()> { let recipes = load_recipes(opts.scope, opts.global_dir.as_deref())?; let filtered = filter_recipes(recipes, opts.query.as_deref()); if filtered.is_empty() { println!("No recipes found."); return Ok(()); } for recipe in filtered { let tags = if recipe.tags.is_empty() { String::new() } else { format!(" [{}]", recipe.tags.join(",")) }; println!( "{:<7} {:<36} {}{}", recipe.scope.as_str(), recipe.id, recipe.name, tags ); if !recipe.description.is_empty() { println!(" {}", recipe.description); } } Ok(()) } fn search_recipes(opts: RecipeSearchOpts) -> Result<()> { let recipes = load_recipes(opts.scope, opts.global_dir.as_deref())?; let filtered = filter_recipes(recipes, Some(opts.query.as_str())); if filtered.is_empty() { println!("No recipes matched '{}'.", opts.query); return Ok(()); } for recipe in filtered { println!( "{:<7} {:<36} {}", recipe.scope.as_str(), recipe.id, recipe.name ); } Ok(()) } fn run_recipe(opts: RecipeRunOpts) -> Result<()> { let recipes = load_recipes(opts.scope, opts.global_dir.as_deref())?; let recipe = match select_recipe(&recipes, &opts.selector) { Ok(recipe) => recipe, Err(err) => { eprintln!("{err}"); bail!("failed to select recipe") } }; let cwd = resolve_cwd(opts.cwd.as_deref())?; println!( "Running recipe {} ({}) from {}", recipe.id, recipe.scope.as_str(), recipe.path.display() ); println!("cwd: {}", cwd.display()); match &recipe.runner { RecipeRunner::Shell { shell, command } => { let shell_bin = resolve_shell_bin(shell); let shell_cmd = command.trim(); println!("engine: shell"); println!("shell: {}", shell_bin); println!("cmd: {}", shell_cmd); if opts.dry_run { return Ok(()); } let status = Command::new(&shell_bin) .arg("-lc") .arg(shell_cmd) .current_dir(&cwd) .status() .with_context(|| format!("failed to run recipe command via {}", shell_bin))?; if !status.success() { bail!("recipe '{}' failed with status {}", recipe.id, status); } } RecipeRunner::MoonbitFile => { println!("engine: moonbit"); println!("cmd: moon run {}", recipe.path.display()); if opts.dry_run { return Ok(()); } let status = Command::new("moon") .arg("run") .arg(&recipe.path) .current_dir(&cwd) .status() .with_context(|| format!("failed to run moon recipe {}", recipe.path.display()))?; if !status.success() { bail!("recipe '{}' failed with status {}", recipe.id, status); } } } Ok(()) } fn init_recipes(opts: RecipeInitOpts) -> Result<()> { let project_root = detect_project_root()?; let global_dir = resolve_global_dir(&project_root, opts.global_dir.as_deref()); let mut created: Vec<PathBuf> = Vec::new(); let mut created_files: Vec<PathBuf> = Vec::new(); if matches!(opts.scope, RecipeScopeArg::Project | RecipeScopeArg::All) { let project_dir = project_root.join(".ai/recipes/project"); ensure_dir(&project_dir, &mut created)?; write_starter_recipe( &project_dir.join("open-safari-new-tab.md"), STARTER_PROJECT_RECIPE, &mut created_files, )?; write_starter_recipe( &project_dir.join("bridge-latency-bench.md"), STARTER_PROJECT_BENCH_RECIPE, &mut created_files, )?; write_starter_recipe( &project_dir.join("moonbit-starter.mbt"), STARTER_PROJECT_MOONBIT_RECIPE, &mut created_files, )?; } if matches!(opts.scope, RecipeScopeArg::Global | RecipeScopeArg::All) { ensure_dir(&global_dir, &mut created)?; write_starter_recipe( &global_dir.join("system-ready-check.md"), STARTER_GLOBAL_RECIPE, &mut created_files, )?; } if created.is_empty() && created_files.is_empty() { println!("Recipe directories already initialized."); } else { for dir in created { println!("created dir: {}", dir.display()); } for file in created_files { println!("created recipe: {}", file.display()); } } Ok(()) } fn load_recipes(scope: RecipeScopeArg, global_dir_override: Option<&str>) -> Result<Vec<Recipe>> { let project_root = detect_project_root()?; let mut roots: Vec<(Scope, PathBuf)> = Vec::new(); if matches!(scope, RecipeScopeArg::Project | RecipeScopeArg::All) { let preferred = project_root.join(".ai/recipes/project"); if preferred.exists() { roots.push((Scope::Project, preferred)); } else { roots.push((Scope::Project, project_root.join(".ai/recipes"))); } } if matches!(scope, RecipeScopeArg::Global | RecipeScopeArg::All) { roots.push(( Scope::Global, resolve_global_dir(&project_root, global_dir_override), )); } let mut seen = BTreeSet::new(); roots.retain(|(_, root)| seen.insert(root.clone())); let mut recipes = Vec::new(); let mut seen_recipe_paths = BTreeSet::new(); for (scope, root) in roots { if !root.exists() { continue; } let files = collect_recipe_files(&root)?; for file in files { let key = (scope, file.clone()); if !seen_recipe_paths.insert(key) { continue; } if let Some(recipe) = parse_recipe(scope, &root, &file)? { recipes.push(recipe); } } } recipes.sort_by(|a, b| (a.scope, a.id.as_str()).cmp(&(b.scope, b.id.as_str()))); Ok(recipes) } fn collect_recipe_files(root: &Path) -> Result<Vec<PathBuf>> { let mut out = Vec::new(); collect_recipe_files_recursive(root, &mut out)?; out.sort(); Ok(out) } fn collect_recipe_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> { for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { let entry = entry.with_context(|| format!("failed to read entry in {}", dir.display()))?; let path = entry.path(); let ty = entry .file_type() .with_context(|| format!("failed to get type for {}", path.display()))?; if ty.is_dir() { collect_recipe_files_recursive(&path, out)?; continue; } if !ty.is_file() { continue; } let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or_default() .to_ascii_lowercase(); if ext == "md" || ext == "markdown" || ext == "mbt" { out.push(path); } } Ok(()) } fn parse_recipe(scope: Scope, root: &Path, path: &Path) -> Result<Option<Recipe>> { let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or_default() .to_ascii_lowercase(); if ext == "mbt" { return parse_moonbit_recipe(scope, root, path); } parse_markdown_recipe(scope, root, path) } fn parse_markdown_recipe(scope: Scope, root: &Path, path: &Path) -> Result<Option<Recipe>> { let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; let (frontmatter, body) = parse_frontmatter(&content); let (shell, command) = match extract_first_shell_block(body) { Some(found) => found, None => return Ok(None), }; let relative = path.strip_prefix(root).unwrap_or(path); let id = format!( "{}:{}", scope.as_str(), relative .with_extension("") .to_string_lossy() .replace('\\', "/") ); let title = frontmatter .title .or_else(|| extract_first_heading(body)) .unwrap_or_else(|| { path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("recipe") .replace('-', " ") }); let description = frontmatter .description .or_else(|| extract_description(body)) .unwrap_or_default(); Ok(Some(Recipe { id, name: title, description, path: path.to_path_buf(), scope, runner: RecipeRunner::Shell { shell, command }, tags: frontmatter.tags, })) } fn parse_moonbit_recipe(scope: Scope, root: &Path, path: &Path) -> Result<Option<Recipe>> { let content = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; let frontmatter = parse_moonbit_metadata(&content); let relative = path.strip_prefix(root).unwrap_or(path); let id = format!( "{}:{}", scope.as_str(), relative .with_extension("") .to_string_lossy() .replace('\\', "/") ); let title = frontmatter.title.unwrap_or_else(|| { path.file_stem() .and_then(|s| s.to_str()) .unwrap_or("recipe") .replace(['-', '_'], " ") }); let description = frontmatter.description.unwrap_or_default(); Ok(Some(Recipe { id, name: title, description, path: path.to_path_buf(), scope, runner: RecipeRunner::MoonbitFile, tags: frontmatter.tags, })) } fn parse_moonbit_metadata(content: &str) -> Frontmatter { let mut fm = Frontmatter::default(); for raw in content.lines() { let line = raw.trim(); if line.is_empty() { continue; } let Some(comment) = line.strip_prefix("//") else { break; }; let comment = comment.trim(); let Some((key, value)) = comment.split_once(':') else { continue; }; let key = key.trim().to_ascii_lowercase(); let value = value.trim(); if key == "title" { fm.title = Some(strip_quotes(value)); } else if key == "description" { fm.description = Some(strip_quotes(value)); } else if key == "tags" { fm.tags = parse_tags(value); } } fm } fn parse_frontmatter(content: &str) -> (Frontmatter, &str) { let mut fm = Frontmatter::default(); if !content.starts_with("---\n") { return (fm, content); } let rest = &content[4..]; let Some(end_idx) = rest.find("\n---\n") else { return (fm, content); }; let block = &rest[..end_idx]; let body = &rest[end_idx + 5..]; for raw in block.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = line.split_once(':') else { continue; }; let key = key.trim().to_ascii_lowercase(); let value = value.trim(); if key == "title" { fm.title = Some(strip_quotes(value)); } else if key == "description" { fm.description = Some(strip_quotes(value)); } else if key == "tags" { fm.tags = parse_tags(value); } } (fm, body) } fn parse_tags(value: &str) -> Vec<String> { let v = strip_quotes(value); let trimmed = v.trim(); let inner = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { &trimmed[1..trimmed.len() - 1] } else { trimmed }; inner .split(',') .map(strip_quotes) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } fn strip_quotes(value: &str) -> String { let trimmed = value.trim(); if trimmed.len() >= 2 { let bytes = trimmed.as_bytes(); if (bytes[0] == b'"' && bytes[trimmed.len() - 1] == b'"') || (bytes[0] == b'\'' && bytes[trimmed.len() - 1] == b'\'') { return trimmed[1..trimmed.len() - 1].to_string(); } } trimmed.to_string() } fn extract_first_heading(body: &str) -> Option<String> { for raw in body.lines() { let line = raw.trim(); if let Some(title) = line.strip_prefix("# ") { let title = title.trim(); if !title.is_empty() { return Some(title.to_string()); } } } None } fn extract_description(body: &str) -> Option<String> { let mut in_fence = false; for raw in body.lines() { let line = raw.trim(); if line.starts_with("```") { in_fence = !in_fence; continue; } if in_fence || line.is_empty() || line.starts_with('#') { continue; } return Some(line.to_string()); } None } fn extract_first_shell_block(body: &str) -> Option<(String, String)> { let mut in_block = false; let mut capture = false; let mut shell = String::from("sh"); let mut lines: Vec<String> = Vec::new(); for raw in body.lines() { let line = raw.trim_end_matches('\r'); let trimmed = line.trim_start(); if trimmed.starts_with("```") { if in_block { if capture { let command = lines.join("\n").trim().to_string(); if !command.is_empty() { return Some((shell, command)); } } in_block = false; capture = false; lines.clear(); continue; } in_block = true; let fence_info = trimmed.trim_start_matches("```").trim(); let lang = fence_info .split_whitespace() .next() .unwrap_or_default() .to_ascii_lowercase(); if is_shell_lang(&lang) { capture = true; shell = normalize_shell_lang(&lang); } else { capture = false; } continue; } if in_block && capture { lines.push(line.to_string()); } } None } fn is_shell_lang(lang: &str) -> bool { matches!(lang, "" | "sh" | "bash" | "zsh" | "shell" | "fish") } fn normalize_shell_lang(lang: &str) -> String { let normalized = lang.trim().to_ascii_lowercase(); if normalized.is_empty() || normalized == "shell" { "sh".to_string() } else { normalized } } fn resolve_shell_bin(shell: &str) -> String { let token = shell.split_whitespace().next().unwrap_or("").trim(); match token.to_ascii_lowercase().as_str() { "" | "sh" | "shell" => "/bin/sh".to_string(), "bash" => "bash".to_string(), "zsh" => "zsh".to_string(), "fish" => "fish".to_string(), other => { if other.is_empty() { "/bin/sh".to_string() } else { token.to_string() } } } } fn filter_recipes(recipes: Vec<Recipe>, query: Option<&str>) -> Vec<Recipe> { let Some(query) = query.map(|q| q.trim()).filter(|q| !q.is_empty()) else { return recipes; }; let needle = query.to_ascii_lowercase(); recipes .into_iter() .filter(|r| { let mut hay = String::new(); hay.push_str(&r.id); hay.push(' '); hay.push_str(&r.name); hay.push(' '); hay.push_str(&r.description); if !r.tags.is_empty() { hay.push(' '); hay.push_str(&r.tags.join(" ")); } hay.to_ascii_lowercase().contains(&needle) }) .collect() } fn select_recipe<'a>(recipes: &'a [Recipe], selector: &str) -> Result<&'a Recipe> { let normalized = selector.trim(); if normalized.is_empty() { bail!("empty recipe selector") } if let Some(recipe) = recipes.iter().find(|r| r.id == normalized) { return Ok(recipe); } let lowered = normalized.to_ascii_lowercase(); let exact_name: Vec<&Recipe> = recipes .iter() .filter(|r| r.name.to_ascii_lowercase() == lowered) .collect(); if exact_name.len() == 1 { return Ok(exact_name[0]); } if exact_name.len() > 1 { ambiguous_selector_error(selector, &exact_name)?; bail!("ambiguous recipe selector") } let contains: Vec<&Recipe> = recipes .iter() .filter(|r| { r.id.to_ascii_lowercase().contains(&lowered) || r.name.to_ascii_lowercase().contains(&lowered) }) .collect(); if contains.len() == 1 { return Ok(contains[0]); } if contains.is_empty() { bail!("no recipe matched '{}'", selector); } ambiguous_selector_error(selector, &contains)?; bail!("ambiguous recipe selector") } fn ambiguous_selector_error(selector: &str, matches: &[&Recipe]) -> Result<()> { eprintln!("recipe selector '{}' matched multiple recipes:", selector); for recipe in matches { eprintln!(" - {} ({})", recipe.id, recipe.name); } Ok(()) } fn resolve_cwd(cwd: Option<&str>) -> Result<PathBuf> { if let Some(cwd) = cwd { return Ok(expand_tilde(cwd)); } detect_project_root() } fn detect_project_root() -> Result<PathBuf> { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output(); if let Ok(out) = output && out.status.success() { let root = String::from_utf8_lossy(&out.stdout).trim().to_string(); if !root.is_empty() { return Ok(PathBuf::from(root)); } } env::current_dir().context("failed to resolve current directory") } fn resolve_global_dir(project_root: &Path, override_dir: Option<&str>) -> PathBuf { let env_override = env::var(ENV_GLOBAL_RECIPE_DIR).ok(); resolve_global_dir_with_env(project_root, override_dir, env_override.as_deref()) } fn resolve_global_dir_with_env( _project_root: &Path, override_dir: Option<&str>, env_override: Option<&str>, ) -> PathBuf { if let Some(dir) = override_dir { return expand_tilde(dir); } if let Some(dir) = env_override && !dir.trim().is_empty() { return expand_tilde(dir); } config::global_config_dir().join("recipes") } fn expand_tilde(path: &str) -> PathBuf { PathBuf::from(tilde(path).to_string()) } fn ensure_dir(dir: &Path, created: &mut Vec<PathBuf>) -> Result<()> { if dir.exists() { return Ok(()); } fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?; created.push(dir.to_path_buf()); Ok(()) } fn write_starter_recipe(path: &Path, content: &str, created: &mut Vec<PathBuf>) -> Result<()> { if path.exists() { return Ok(()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; created.push(path.to_path_buf()); Ok(()) } const STARTER_PROJECT_RECIPE: &str = r#"--- title: Open Safari New Tab description: Fast local smoke command for seq integration. tags: [seq, app] --- Open Safari via seq and create a new tab. ```sh ~/code/seq/cli/cpp/out/bin/seq run "open Safari new tab" ``` "#; const STARTER_PROJECT_BENCH_RECIPE: &str = r#"--- title: Kar User Command Bench description: Run transport-focused bridge latency benchmark. tags: [benchmark, latency, karabiner] --- ```sh python3 tools/bridge_latency_bench.py --build-if-missing --iterations 300 --warmup 40 ``` "#; const STARTER_PROJECT_MOONBIT_RECIPE: &str = r#"// title: MoonBit Recipe Starter // description: Minimal runnable MoonBit recipe entry. // tags: [moonbit, recipe] fn main { println("hello from moonbit recipe") } "#; const STARTER_GLOBAL_RECIPE: &str = r#"--- title: System Ready Check description: Verify machine is in clean state before latency benchmarks. tags: [system, benchmark] --- ```sh f kar-uc-system-check-report || true ``` "#; #[cfg(test)] mod tests { use super::*; #[test] fn frontmatter_parses_basic_fields() { let text = "---\n\ title: Hello\n\ description: World\n\ tags: [a, b]\n\ ---\n\ # Heading\n"; let (fm, body) = parse_frontmatter(text); assert_eq!(fm.title.as_deref(), Some("Hello")); assert_eq!(fm.description.as_deref(), Some("World")); assert_eq!(fm.tags, vec!["a".to_string(), "b".to_string()]); assert!(body.starts_with("# Heading")); } #[test] fn extracts_shell_block() { let body = "# T\n\n```bash\necho hi\n```\n"; let (shell, command) = extract_first_shell_block(body).expect("shell block"); assert_eq!(shell, "bash"); assert_eq!(command, "echo hi"); } #[test] fn extracts_shell_block_with_fence_metadata() { let body = "# T\n\n```zsh title=\"run\"\necho hi\n```\n"; let (shell, command) = extract_first_shell_block(body).expect("shell block"); assert_eq!(shell, "zsh"); assert_eq!(command, "echo hi"); } #[test] fn normalize_shell_lang_handles_shell_alias() { assert_eq!(normalize_shell_lang("shell"), "sh"); assert_eq!(normalize_shell_lang(""), "sh"); } #[test] fn resolve_shell_bin_honors_declared_shell() { assert_eq!(resolve_shell_bin("bash"), "bash"); assert_eq!(resolve_shell_bin("zsh"), "zsh"); assert_eq!(resolve_shell_bin("fish"), "fish"); assert_eq!(resolve_shell_bin("sh"), "/bin/sh"); assert_eq!(resolve_shell_bin("shell"), "/bin/sh"); } #[test] fn resolve_global_dir_prefers_override_then_env_then_config() { let root = PathBuf::from("/tmp/project"); let override_dir = resolve_global_dir_with_env(&root, Some("~/recipes-x"), None); assert!( override_dir.to_string_lossy().contains("recipes-x"), "override dir should be used" ); let env_dir = resolve_global_dir_with_env(&root, None, Some("~/recipes-y")); assert!( env_dir.to_string_lossy().contains("recipes-y"), "env dir should be used" ); let cfg_dir = resolve_global_dir_with_env(&root, None, None); assert_eq!(cfg_dir, config::global_config_dir().join("recipes")); } #[test] fn filter_matches_name_and_tags() { let recipes = vec![Recipe { id: "project:a".to_string(), name: "Open Safari".to_string(), description: "fast".to_string(), path: PathBuf::from("a.md"), scope: Scope::Project, runner: RecipeRunner::Shell { shell: "sh".to_string(), command: "echo".to_string(), }, tags: vec!["browser".to_string()], }]; let out = filter_recipes(recipes, Some("browser")); assert_eq!(out.len(), 1); } #[test] fn parses_moonbit_metadata_header() { let text = "// title: Fast App Open\n\ // description: open app with moonbit\n\ // tags: [moonbit, fast]\n\ \n\ fn main {\n\ println(\"ok\")\n\ }\n"; let fm = parse_moonbit_metadata(text); assert_eq!(fm.title.as_deref(), Some("Fast App Open")); assert_eq!(fm.description.as_deref(), Some("open app with moonbit")); assert_eq!(fm.tags, vec!["moonbit".to_string(), "fast".to_string()]); } } ================================================ FILE: src/registry.rs ================================================ use std::collections::BTreeMap; use std::env; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{Datelike, Local, Utc}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tempfile::NamedTempFile; use crate::cli::{ InstallOpts, RegistryAction, RegistryCommand, RegistryInitOpts, RegistryReleaseOpts, }; use crate::config::{self, Config, RegistryReleaseConfig}; use crate::env as flow_env; const DEFAULT_TOKEN_ENV: &str = "FLOW_REGISTRY_TOKEN"; const WORKER_TOKEN_SECRET: &str = "REGISTRY_TOKEN"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryManifest { pub name: String, pub version: String, pub published_at: String, #[serde(default)] pub bins: Vec<String>, #[serde(default)] pub default_bin: Option<String>, pub targets: BTreeMap<String, RegistryTarget>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryTarget { pub binaries: BTreeMap<String, String>, #[serde(default)] pub sha256: BTreeMap<String, String>, } pub fn run(cmd: RegistryCommand) -> Result<()> { match cmd.action { Some(RegistryAction::Init(opts)) => init(opts), None => { println!("Registry commands:"); println!(" init Create a registry token and configure worker secrets"); Ok(()) } } } pub fn init(opts: RegistryInitOpts) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; let flow_path = find_flow_toml(&cwd); let (project_root, flow_cfg) = if let Some(flow_path) = flow_path.as_ref() { let cfg = config::load(flow_path)?; let root = flow_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| cwd.clone()); (root, Some(cfg)) } else { (cwd.clone(), None) }; let registry_cfg = flow_cfg .as_ref() .and_then(|cfg| cfg.release.as_ref()) .and_then(|release| release.registry.as_ref()); let token_env = opts .token_env .clone() .or_else(|| registry_cfg.and_then(|cfg| cfg.token_env.clone())) .unwrap_or_else(|| DEFAULT_TOKEN_ENV.to_string()); let registry_url = resolve_registry_url(opts.registry.as_deref(), registry_cfg).ok(); let token = opts.token.unwrap_or_else(generate_registry_token); flow_env::set_personal_env_var(&token_env, &token)?; if opts.no_worker { println!("Skipped worker secret setup (--no-worker)."); } else { let worker_path = resolve_worker_path(opts.worker.as_ref(), &project_root)? .context("worker path not found; pass --worker to set secrets")?; set_worker_secret(&worker_path, &token)?; } if let Some(registry_url) = registry_url { println!("Registry URL: {}", registry_url); } if opts.show_token { println!("Registry token: {}", token); } else { let preview = token.chars().take(6).collect::<String>(); println!("Registry token: {}… (use --show-token to print)", preview); } println!("Ready to release with `f release`."); Ok(()) } pub fn publish(config_path: &Path, cfg: &Config, opts: RegistryReleaseOpts) -> Result<()> { let project_root = config_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); let registry_cfg = cfg .release .as_ref() .and_then(|release| release.registry.as_ref()); let registry_url = resolve_registry_url(opts.registry.as_deref(), registry_cfg)?; let package = resolve_package_name(opts.package.clone(), cfg, registry_cfg, &project_root)?; let bins = resolve_bins(&package, opts.bin.clone(), registry_cfg); let default_bin = resolve_default_bin(&package, &bins, registry_cfg); let version = resolve_registry_version(cfg, opts.version.clone(), ®istry_url, &package)?; let latest = resolve_latest_flag(opts.latest, opts.no_latest, registry_cfg); if !opts.no_build { build_binaries(&project_root, &bins)?; } let target = detect_target_triple()?; let mut binaries = BTreeMap::new(); let mut sha256_map = BTreeMap::new(); for bin in &bins { let path = project_root.join("target").join("release").join(bin); if !path.exists() { bail!("binary not found: {}", path.display()); } let sha = sha256_file(&path)?; let key = format!("packages/{}/{}/{}/{}", package, version, target, bin); binaries.insert(bin.clone(), key); sha256_map.insert(bin.clone(), sha); } let mut targets = BTreeMap::new(); targets.insert( target.clone(), RegistryTarget { binaries, sha256: sha256_map, }, ); let manifest = RegistryManifest { name: package.clone(), version: version.clone(), published_at: Utc::now().to_rfc3339(), bins: bins.clone(), default_bin, targets, }; if opts.dry_run { println!( "Dry run: would publish {} {} to {} (target {})", package, version, registry_url, target ); return Ok(()); } let token_env = registry_cfg .and_then(|cfg| cfg.token_env.as_ref()) .map(|s| s.as_str()) .unwrap_or(DEFAULT_TOKEN_ENV); let token = resolve_registry_token(token_env)?; let client = Client::builder().timeout(Duration::from_secs(60)).build()?; for bin in &bins { let path = project_root.join("target").join("release").join(bin); let key = format!("packages/{}/{}/{}/{}", package, version, target, bin); let url = format!("{}/{}", registry_url, key); let body = fs::read(&path)?; let sha = sha256_file(&path)?; let response = client .put(url) .header("Authorization", format!("Bearer {}", token)) .header("X-Sha256", sha) .body(body) .send() .context("failed to upload binary")?; if !response.status().is_success() { bail!("registry upload failed for {} ({})", bin, response.status()); } } let manifest_url = format!( "{}/packages/{}/{}/manifest.json", registry_url, package, version ); let mut request = client .put(manifest_url) .header("Authorization", format!("Bearer {}", token)) .body(serde_json::to_string_pretty(&manifest)?); if latest { request = request.query(&[("latest", "1")]); } let response = request.send().context("failed to upload manifest")?; if !response.status().is_success() { bail!("registry manifest upload failed ({})", response.status()); } println!("Published {} {} to {}", package, version, registry_url); Ok(()) } pub fn install(opts: InstallOpts) -> Result<()> { let name = opts.name.as_deref().unwrap_or("").trim().to_string(); if name.is_empty() { bail!("package name is required for registry install"); } let global_registry = load_global_registry_config(); let registry_url = resolve_registry_url(opts.registry.as_deref(), global_registry.as_ref())?; let client = Client::builder().timeout(Duration::from_secs(60)).build()?; let version = opts.version.clone(); let manifest = fetch_manifest(&client, ®istry_url, &name, version.as_deref())?; let target = detect_target_triple()?; let target_entry = manifest .targets .get(&target) .with_context(|| format!("No binaries for target {}", target))?; let bin = resolve_install_bin(&name, &opts.bin, &manifest, target_entry)?; let path = target_entry .binaries .get(&bin) .with_context(|| format!("No binary '{}' in manifest", bin))?; let download_url = resolve_download_url(®istry_url, path); let response = client .get(download_url) .send() .context("failed to download binary")?; if !response.status().is_success() { bail!("download failed ({})", response.status()); } let bytes = response.bytes().context("failed to read download")?; if !opts.no_verify { if let Some(expected) = target_entry.sha256.get(&bin) { let actual = sha256_bytes(&bytes); if expected != &actual { bail!("checksum mismatch for {}", bin); } } } let bin_dir = opts.bin_dir.clone().unwrap_or_else(default_bin_dir); fs::create_dir_all(&bin_dir) .with_context(|| format!("failed to create {}", bin_dir.display()))?; let dest = bin_dir.join(&bin); if dest.exists() && !opts.force { bail!( "{} already exists (use --force to overwrite)", dest.display() ); } let mut temp = NamedTempFile::new_in(&bin_dir) .with_context(|| format!("failed to create temp file in {}", bin_dir.display()))?; temp.write_all(&bytes)?; temp.flush()?; persist_with_permissions(temp, &dest)?; println!("Installed {} to {}", bin, dest.display()); if !path_in_env(&bin_dir) { println!("Add {} to PATH to use it everywhere.", bin_dir.display()); } Ok(()) } fn resolve_registry_url( override_url: Option<&str>, cfg: Option<&RegistryReleaseConfig>, ) -> Result<String> { let url = override_url .map(|s| s.to_string()) .or_else(|| cfg.and_then(|cfg| cfg.url.clone())) .or_else(|| env::var("FLOW_REGISTRY_URL").ok()) .unwrap_or_else(|| "https://myflow.sh".to_string()); Ok(url.trim_end_matches('/').to_string()) } fn load_global_registry_config() -> Option<RegistryReleaseConfig> { let path = config::default_config_path(); if !path.exists() { return None; } let cfg = config::load(&path).ok()?; cfg.release.and_then(|release| release.registry) } fn resolve_package_name( override_package: Option<String>, cfg: &Config, registry_cfg: Option<&RegistryReleaseConfig>, project_root: &Path, ) -> Result<String> { if let Some(value) = override_package { return Ok(value); } if let Some(cfg) = registry_cfg.and_then(|cfg| cfg.package.clone()) { return Ok(cfg); } if let Some(name) = cfg.project_name.clone() { return Ok(name); } let fallback = project_root .file_name() .and_then(|name| name.to_str()) .unwrap_or("package"); Ok(fallback.to_string()) } fn resolve_bins( package: &str, override_bins: Vec<String>, registry_cfg: Option<&RegistryReleaseConfig>, ) -> Vec<String> { if !override_bins.is_empty() { return override_bins; } if let Some(bins) = registry_cfg.and_then(|cfg| cfg.bins.clone()) { return bins; } vec![package.to_string()] } fn resolve_default_bin( package: &str, bins: &[String], registry_cfg: Option<&RegistryReleaseConfig>, ) -> Option<String> { if let Some(default_bin) = registry_cfg.and_then(|cfg| cfg.default_bin.clone()) { return Some(default_bin); } if bins.iter().any(|bin| bin == package) { return Some(package.to_string()); } bins.first().cloned() } fn resolve_latest_flag( latest: bool, no_latest: bool, registry_cfg: Option<&RegistryReleaseConfig>, ) -> bool { if latest { return true; } if no_latest { return false; } registry_cfg.and_then(|cfg| cfg.latest).unwrap_or(true) } fn resolve_registry_version( cfg: &Config, version: Option<String>, registry_url: &str, package: &str, ) -> Result<String> { if let Some(version) = version { return Ok(version); } let versioning = cfg .release .as_ref() .and_then(|release| release.versioning.as_deref()); match versioning { Some("calver") | Some("calendar") | Some("date") => { Ok(calver_version(cfg, registry_url, package)) } _ => bail!("Version not provided. Pass --version or set release.versioning."), } } fn calver_version(cfg: &Config, registry_url: &str, package: &str) -> String { let now = Local::now(); let mut base = format!("{}.{}.{}", now.year(), now.month(), now.day()); let suffix = cfg .release .as_ref() .and_then(|release| release.calver_suffix.clone()) .or_else(|| env::var("FLOW_CALVER_SUFFIX").ok()); if let Some(suffix) = suffix { let trimmed = suffix.trim(); if !trimmed.is_empty() { base = format!("{}-{}", base, trimmed); } return base; } if let Ok(versions) = fetch_registry_versions(registry_url, package) { let mut max_suffix: Option<u64> = None; for version in versions { if version == base { max_suffix = Some(max_suffix.unwrap_or(0).max(0)); continue; } if let Some(rest) = version.strip_prefix(&format!("{}-", base)) { if let Ok(num) = rest.parse::<u64>() { max_suffix = Some(max_suffix.unwrap_or(0).max(num)); } } } if let Some(value) = max_suffix { return format!("{}-{}", base, value + 1); } } base } fn fetch_registry_versions(registry_url: &str, package: &str) -> Result<Vec<String>> { let client = Client::builder().timeout(Duration::from_secs(10)).build()?; let url = format!("{}/packages/{}/versions.json", registry_url, package); let resp = client.get(url).send()?; if resp.status().as_u16() == 404 { return Ok(Vec::new()); } if !resp.status().is_success() { bail!("registry returned {}", resp.status()); } #[derive(Deserialize)] struct VersionsResponse { versions: Vec<String>, } let parsed: VersionsResponse = resp.json()?; Ok(parsed.versions) } fn fetch_manifest( client: &Client, registry_url: &str, name: &str, version: Option<&str>, ) -> Result<RegistryManifest> { let url = match version { Some(version) => format!( "{}/packages/{}/{}/manifest.json", registry_url, name, version ), None => format!("{}/packages/{}/latest.json", registry_url, name), }; let resp = client.get(url).send()?; if resp.status().as_u16() == 404 { bail!("Package '{}' not found in registry", name); } if !resp.status().is_success() { bail!("Registry returned {}", resp.status()); } Ok(resp.json()?) } fn resolve_install_bin( package: &str, override_bin: &Option<String>, manifest: &RegistryManifest, target: &RegistryTarget, ) -> Result<String> { if let Some(bin) = override_bin { return Ok(bin.to_string()); } if let Some(bin) = manifest.default_bin.as_ref() { return Ok(bin.clone()); } if manifest.bins.len() == 1 { return Ok(manifest.bins[0].clone()); } if target.binaries.contains_key(package) { return Ok(package.to_string()); } if let Some(first) = target.binaries.keys().next() { return Ok(first.clone()); } bail!("No binaries available for this target"); } fn resolve_download_url(base: &str, path: &str) -> String { if path.starts_with("http://") || path.starts_with("https://") { return path.to_string(); } format!( "{}/{}", base.trim_end_matches('/'), path.trim_start_matches('/') ) } fn resolve_registry_token(token_env: &str) -> Result<String> { if let Ok(token) = env::var(token_env) { if !token.trim().is_empty() { return Ok(token); } } let vars = flow_env::fetch_personal_env_vars(&[token_env.to_string()])?; if let Some(token) = vars.get(token_env) { return Ok(token.clone()); } bail!( "{} not set. Add it with `f env new` or export it in your shell.", token_env ); } fn generate_registry_token() -> String { let a = uuid::Uuid::new_v4().simple().to_string(); let b = uuid::Uuid::new_v4().simple().to_string(); format!("flow_{}{}", a, b) } fn resolve_worker_path(explicit: Option<&PathBuf>, project_root: &Path) -> Result<Option<PathBuf>> { if let Some(path) = explicit { return Ok(Some(path.clone())); } let candidates = [ project_root.join("packages").join("worker"), project_root.join("worker"), project_root.to_path_buf(), ]; for candidate in candidates { if has_wrangler_config(&candidate) { return Ok(Some(candidate)); } } Ok(None) } fn has_wrangler_config(path: &Path) -> bool { ["wrangler.toml", "wrangler.json", "wrangler.jsonc"] .iter() .any(|name| path.join(name).exists()) } fn set_worker_secret(worker_path: &Path, token: &str) -> Result<()> { let mut child = Command::new("wrangler") .arg("secret") .arg("put") .arg(WORKER_TOKEN_SECRET) .current_dir(worker_path) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .spawn() .context("failed to run wrangler secret put")?; { let stdin = child .stdin .as_mut() .context("failed to open wrangler stdin")?; stdin.write_all(token.as_bytes())?; stdin.write_all(b"\n")?; } let status = child.wait()?; if !status.success() { bail!("wrangler secret put failed"); } println!( "✓ Set {} in worker config ({})", WORKER_TOKEN_SECRET, worker_path.display() ); Ok(()) } fn find_flow_toml(start: &Path) -> Option<PathBuf> { let mut current = start.to_path_buf(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } fn build_binaries(project_root: &Path, bins: &[String]) -> Result<()> { let mut command = Command::new("cargo"); command.arg("build").arg("--release"); for bin in bins { command.arg("--bin").arg(bin); } let status = command .current_dir(project_root) .status() .context("failed to run cargo build")?; if !status.success() { bail!("cargo build failed"); } Ok(()) } fn detect_target_triple() -> Result<String> { let os = if cfg!(target_os = "macos") { "apple-darwin" } else if cfg!(target_os = "linux") { "unknown-linux-gnu" } else if cfg!(target_os = "windows") { "pc-windows-msvc" } else { bail!("Unsupported operating system"); }; let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else if cfg!(target_arch = "x86_64") { "x86_64" } else { bail!("Unsupported architecture"); }; Ok(format!("{}-{}", arch, os)) } fn sha256_file(path: &Path) -> Result<String> { let data = fs::read(path)?; Ok(sha256_bytes(&data)) } fn sha256_bytes(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); let digest = hasher.finalize(); hex::encode(digest) } fn default_bin_dir() -> PathBuf { let home = env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")); // Prefer ~/.flow/bin (already on PATH from install.sh) let flow_bin = home.join(".flow").join("bin"); if flow_bin.exists() { return flow_bin; } home.join("bin") } fn persist_with_permissions(temp: NamedTempFile, dest: &Path) -> Result<()> { temp.persist(dest) .map_err(|err| err.error) .context("failed to persist binary")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(dest)?.permissions(); perms.set_mode(0o755); fs::set_permissions(dest, perms)?; } Ok(()) } fn path_in_env(bin_dir: &Path) -> bool { let path = env::var_os("PATH").unwrap_or_default(); env::split_paths(&path).any(|entry| entry == bin_dir) } #[cfg(test)] mod tests { use super::*; #[test] fn resolves_relative_download_url() { let url = resolve_download_url("https://example.com", "packages/foo/bin"); assert_eq!(url, "https://example.com/packages/foo/bin"); } #[test] fn resolves_default_bin_from_manifest() { let mut binaries = BTreeMap::new(); binaries.insert("flow".to_string(), "path".to_string()); let target = RegistryTarget { binaries, sha256: BTreeMap::new(), }; let manifest = RegistryManifest { name: "flow".to_string(), version: "1.0.0".to_string(), published_at: "now".to_string(), bins: vec!["flow".to_string()], default_bin: Some("flow".to_string()), targets: BTreeMap::new(), }; let bin = resolve_install_bin("flow", &None, &manifest, &target).unwrap(); assert_eq!(bin, "flow"); } } ================================================ FILE: src/release.rs ================================================ use anyhow::{Result, bail}; use crate::{ cli::{GhReleaseCommand, ReleaseAction, ReleaseCommand, ReleaseOpts}, config::Config, gh_release, registry, release_signing, tasks::{self, find_task}, }; use std::path::Path; fn available_tasks(cfg: &crate::config::Config) -> String { let mut names: Vec<_> = cfg.tasks.iter().map(|task| task.name.clone()).collect(); names.sort(); names.join(", ") } fn resolve_release_task(cfg: &crate::config::Config) -> Result<String> { if let Some(name) = cfg.flow.release_task.as_deref() { if find_task(cfg, name).is_some() { return Ok(name.to_string()); } bail!( "release_task '{}' not found. Available tasks: {}", name, available_tasks(cfg) ); } for fallback in ["release", "release-build"] { if find_task(cfg, fallback).is_some() { return Ok(fallback.to_string()); } } if let Some(name) = cfg.flow.primary_task.as_deref() { if find_task(cfg, name).is_some() { return Ok(name.to_string()); } } bail!( "no release task found. Configure flow.release_task or add a 'release' task. Available tasks: {}", available_tasks(cfg) ); } pub fn run(cmd: ReleaseCommand) -> Result<()> { if let Some(action) = cmd.action.clone() { match action { ReleaseAction::Github(cmd) => return gh_release::run(cmd), ReleaseAction::Signing(cmd) => return release_signing::run(cmd), _ => {} } } let (config_path, cfg) = tasks::load_project_config(cmd.config.clone())?; match cmd.action { Some(ReleaseAction::Github(cmd)) => gh_release::run(cmd), Some(ReleaseAction::Registry(opts)) => registry::publish(&config_path, &cfg, opts), Some(ReleaseAction::Task(opts)) => run_task(ReleaseOpts { config: config_path, args: opts.args, }), Some(ReleaseAction::Signing(cmd)) => release_signing::run(cmd), None => run_default(&config_path, &cfg), } } pub fn run_task(opts: ReleaseOpts) -> Result<()> { let (config_path, cfg) = tasks::load_project_config(opts.config)?; let task_name = resolve_release_task(&cfg)?; tasks::run(crate::cli::TaskRunOpts { config: config_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: task_name, args: opts.args, }) } fn run_default(config_path: &Path, cfg: &Config) -> Result<()> { let provider = cfg .release .as_ref() .and_then(|release| release.default.as_deref()) .or_else(|| { cfg.release .as_ref() .and_then(|release| release.registry.as_ref()) .map(|_| "registry") }) .unwrap_or("task"); match provider { "registry" => { registry::publish(config_path, cfg, crate::cli::RegistryReleaseOpts::default()) } "task" | "release" => run_task(ReleaseOpts { config: config_path.to_path_buf(), args: Vec::new(), }), "github" | "gh" => gh_release::run(GhReleaseCommand { action: None }), other => bail!( "Unknown release provider '{}'. Expected registry, task, or github.", other ), } } ================================================ FILE: src/release_signing.rs ================================================ use anyhow::{Context, Result, bail}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STD}; use std::{ fs, io::Write, process::{Command, Stdio}, }; use crate::{ cli::{ ReleaseSigningAction, ReleaseSigningCommand, ReleaseSigningStoreOpts, ReleaseSigningSyncOpts, }, env, }; const SIGNING_KEYS: [&str; 3] = [ "MACOS_SIGN_P12_B64", "MACOS_SIGN_P12_PASSWORD", "MACOS_SIGN_IDENTITY", ]; pub fn run(cmd: ReleaseSigningCommand) -> Result<()> { match cmd.action { ReleaseSigningAction::Status => status(), ReleaseSigningAction::Store(opts) => store(opts), ReleaseSigningAction::Sync(opts) => sync(opts), } } fn status() -> Result<()> { println!("macOS code signing (status)"); println!("──────────────────────────"); if cfg!(target_os = "macos") { let identities = list_codesign_identities().unwrap_or_default(); let mut dev_id = Vec::new(); let mut apple_dev = Vec::new(); for name in identities { if name.starts_with("Developer ID Application:") { dev_id.push(name); } else if name.starts_with("Apple Development:") { apple_dev.push(name); } } if !dev_id.is_empty() { println!("Keychain: Developer ID Application identity found:"); for name in dev_id { println!(" - {}", name); } } else if !apple_dev.is_empty() { println!("Keychain: no Developer ID Application identity found."); println!( "Keychain: Apple Development identity found (not recommended for public distribution):" ); for name in apple_dev { println!(" - {}", name); } println!(); println!( "Next: create/download a Developer ID Application certificate (Apple Developer) and export it as .p12." ); } else { println!("Keychain: no code signing identities found."); } } else { println!("Keychain: not on macOS (skipping)."); } println!(); // This may prompt for Touch ID if using cloud env store. match env::fetch_personal_env_vars( &SIGNING_KEYS .iter() .map(|s| s.to_string()) .collect::<Vec<_>>(), ) { Ok(vars) => { for key in SIGNING_KEYS { if let Some(value) = vars.get(key) { // Avoid leaking secrets; show presence + size only. println!("Env store: {} = set ({} bytes)", key, value.len()); } else { println!("Env store: {} = missing", key); } } } Err(err) => { println!("Env store: unable to read signing keys ({})", err); println!("Next: run `f env login` (cloud) and `f env unlock` (Touch ID), then retry."); } } println!(); println!( "GitHub: `f release signing sync` will copy env store values into GitHub Actions secrets via `gh`." ); Ok(()) } fn list_codesign_identities() -> Result<Vec<String>> { let output = Command::new("security") .args(["find-identity", "-v", "-p", "codesigning"]) .output() .context("failed to run `security find-identity`")?; if !output.status.success() { bail!("`security find-identity` failed"); } let text = String::from_utf8_lossy(&output.stdout); let mut out = Vec::new(); for line in text.lines() { // Example: // 1) <hash> "Developer ID Application: Name (TEAMID)" let Some(quoted) = line.split('"').nth(1) else { continue; }; let name = quoted.trim(); if !name.is_empty() { out.push(name.to_string()); } } Ok(out) } fn store(opts: ReleaseSigningStoreOpts) -> Result<()> { if !cfg!(target_os = "macos") { bail!("release signing store is only supported on macOS"); } let p12_path = opts .p12 .clone() .context("--p12 is required (path to exported .p12)")?; let p12_bytes = fs::read(&p12_path) .with_context(|| format!("failed to read p12 file at {}", p12_path.display()))?; let identity = opts.identity.clone().context("--identity is required")?; let password = opts .p12_password .clone() .context("--p12-password is required")?; if !identity.starts_with("Developer ID Application:") { eprintln!( "Warning: identity does not look like a Developer ID Application certificate: {}", identity ); } let p12_b64 = BASE64_STD.encode(p12_bytes); if opts.dry_run { println!("[dry-run] Would set Flow personal env keys:"); println!(" - MACOS_SIGN_P12_B64 ({} bytes)", p12_b64.len()); println!(" - MACOS_SIGN_P12_PASSWORD ({} bytes)", password.len()); println!(" - MACOS_SIGN_IDENTITY ({} bytes)", identity.len()); return Ok(()); } // Store in Flow personal env store (cloud if logged in; may prompt). env::set_personal_env_var("MACOS_SIGN_P12_B64", &p12_b64)?; env::set_personal_env_var("MACOS_SIGN_P12_PASSWORD", &password)?; env::set_personal_env_var("MACOS_SIGN_IDENTITY", &identity)?; println!("✓ Stored signing materials in Flow personal env store."); Ok(()) } fn sync(opts: ReleaseSigningSyncOpts) -> Result<()> { let keys: Vec<String> = SIGNING_KEYS.iter().map(|k| k.to_string()).collect(); let vars = env::fetch_personal_env_vars(&keys) .context("failed to read signing keys from Flow personal env store")?; if opts.dry_run { println!("[dry-run] Would set GitHub Actions secrets via `gh secret set`:"); for key in SIGNING_KEYS { if vars.contains_key(key) { println!(" - {} (set in env store)", key); } else { println!(" - {} (missing in env store)", key); } } if let Some(repo) = opts.repo.as_deref() { println!("Repo: {}", repo); } else { println!("Repo: (from current directory)"); } if SIGNING_KEYS.iter().any(|k| !vars.contains_key(*k)) { println!(); println!("Next: set missing keys with `f release signing store ...`."); } return Ok(()); } for key in SIGNING_KEYS { if !vars.contains_key(key) { bail!( "missing {} in Flow env store. Set it with `f release signing store ...` (or `f env set {}`) first.", key, key ); } } ensure_gh_available()?; for key in SIGNING_KEYS { let value = vars.get(key).expect("checked above"); gh_secret_set(opts.repo.as_deref(), key, value)?; println!("✓ Set GitHub secret: {}", key); } Ok(()) } fn ensure_gh_available() -> Result<()> { let status = Command::new("gh") .args(["--version"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .context("failed to run `gh` (GitHub CLI)")?; if !status.success() { bail!("`gh` is installed but not working"); } Ok(()) } fn gh_secret_set(repo: Option<&str>, name: &str, value: &str) -> Result<()> { let mut cmd = Command::new("gh"); cmd.args(["secret", "set", name]); if let Some(repo) = repo { cmd.args(["--repo", repo]); } // Avoid passing secrets via argv (ps); `gh secret set` reads from stdin when --body is omitted. let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::null()) .spawn() .with_context(|| format!("failed to spawn `gh secret set {}`", name))?; { let stdin = child .stdin .as_mut() .context("failed to open stdin for gh")?; stdin.write_all(value.as_bytes())?; } let status = child.wait()?; if !status.success() { bail!("`gh secret set {}` failed", name); } Ok(()) } ================================================ FILE: src/repo_capsule.rs ================================================ use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::cli::{RepoAliasAction, RepoAliasCommand, RepoCapsuleOpts}; use crate::{config, project_snapshot}; const DEFAULT_STORE_DIR: &str = "~/repos/garden-co/jazz2/.jazz2/flow-repo-capsules"; const STORE_DIR_ENV: &str = "FLOW_REPO_CAPSULE_STORE"; const CAPSULE_VERSION: u32 = 1; const REGISTRY_FILE: &str = "repo-aliases.json"; const DEFAULT_SHELF_CONFIG: &str = "~/.agents/shelf/config.json"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RepoCapsule { pub version: u32, pub repo_root: String, pub repo_name: String, pub repo_id: String, pub origin_url: Option<String>, pub summary: String, pub languages: Vec<String>, pub manifests: Vec<String>, pub commands: Vec<String>, pub important_paths: Vec<String>, pub docs_hints: Vec<String>, pub updated_at_unix: u64, watched: Vec<PathStamp>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct PathStamp { path: String, exists: bool, len: u64, modified_sec: u64, modified_nsec: u32, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RepoCapsuleReference { pub matched: String, pub repo_root: String, pub output: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RepoAliasEntry { pub alias: String, pub path: String, pub source: String, pub updated_at_unix: u64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct RepoAliasRegistry { version: u32, aliases: Vec<RepoAliasEntry>, } #[derive(Debug, Clone, Serialize)] struct RepoAliasImportSummary { imported: usize, skipped: usize, aliases: Vec<RepoAliasEntry>, } #[derive(Debug, Deserialize)] struct ShelfConfigFile { #[serde(default)] repos: Vec<ShelfRepoEntry>, } #[derive(Debug, Deserialize)] struct ShelfRepoEntry { alias: String, } pub fn run_capsule(opts: RepoCapsuleOpts) -> Result<()> { let target = resolve_target_path(opts.path.as_deref())?; let capsule = if opts.refresh { refresh_capsule_for_path(&target)? } else { load_or_refresh_capsule_for_path(&target)? }; if opts.json { println!("{}", serde_json::to_string_pretty(&capsule)?); } else { print!("{}", render_capsule_report(&capsule)); } Ok(()) } pub fn run_alias(cmd: RepoAliasCommand) -> Result<()> { match cmd.action.unwrap_or(RepoAliasAction::List { json: false }) { RepoAliasAction::List { json } => { let aliases = list_aliases()?; if json { println!("{}", serde_json::to_string_pretty(&aliases)?); } else if aliases.is_empty() { println!("No repo aliases registered."); } else { for entry in aliases { println!("{} -> {} ({})", entry.alias, entry.path, entry.source); } } } RepoAliasAction::Set { alias, path, json } => { let entry = set_alias(&alias, &path, "manual")?; if json { println!("{}", serde_json::to_string_pretty(&entry)?); } else { println!("{} -> {}", entry.alias, entry.path); } } RepoAliasAction::Remove { alias } => { remove_alias(&alias)?; println!("Removed alias {}", normalize_alias(&alias)); } RepoAliasAction::ImportShelf { config, json } => { let summary = import_shelf_aliases(config.as_deref())?; if json { println!("{}", serde_json::to_string_pretty(&summary)?); } else { println!( "Imported {} alias(es), skipped {}.", summary.imported, summary.skipped ); for entry in summary.aliases { println!("{} -> {} ({})", entry.alias, entry.path, entry.source); } } } } Ok(()) } pub fn load_or_refresh_capsule_for_path(path: &Path) -> Result<RepoCapsule> { let root = resolve_reference_root(path)?; load_or_refresh_capsule_for_root(&storage_dir(), &root) } pub fn refresh_capsule_for_path(path: &Path) -> Result<RepoCapsule> { let root = resolve_reference_root(path)?; refresh_capsule_for_root(&storage_dir(), &root) } pub fn list_aliases() -> Result<Vec<RepoAliasEntry>> { let mut aliases = load_alias_registry(&storage_dir())?.aliases; aliases.sort_by(|a, b| a.alias.cmp(&b.alias)); Ok(aliases) } pub fn set_alias(alias: &str, path: &str, source: &str) -> Result<RepoAliasEntry> { set_alias_in_store(&storage_dir(), alias, path, source) } fn set_alias_in_store( store_dir: &Path, alias: &str, path: &str, source: &str, ) -> Result<RepoAliasEntry> { let target = resolve_target_path(Some(path))?; let root = resolve_reference_root(&target)?; let _ = load_or_refresh_capsule_for_root(store_dir, &root)?; let mut registry = load_alias_registry(store_dir)?; let entry = RepoAliasEntry { alias: normalize_alias(alias), path: root.display().to_string(), source: source.to_string(), updated_at_unix: now_unix(), }; registry.aliases.retain(|value| value.alias != entry.alias); registry.aliases.push(entry.clone()); save_alias_registry(store_dir, ®istry)?; Ok(entry) } pub fn remove_alias(alias: &str) -> Result<()> { let store_dir = storage_dir(); let mut registry = load_alias_registry(&store_dir)?; registry .aliases .retain(|value| value.alias != normalize_alias(alias)); save_alias_registry(&store_dir, ®istry) } fn import_shelf_aliases(config_path: Option<&str>) -> Result<RepoAliasImportSummary> { let config_path = config::expand_path(config_path.unwrap_or(DEFAULT_SHELF_CONFIG)); import_shelf_aliases_into_store(&storage_dir(), &config_path) } fn import_shelf_aliases_into_store( store_dir: &Path, config_path: &Path, ) -> Result<RepoAliasImportSummary> { let payload = fs::read_to_string(&config_path) .with_context(|| format!("read {}", config_path.display()))?; let parsed = serde_json::from_str::<ShelfConfigFile>(&payload).context("parse Shelf config JSON")?; let shelf_repos_dir = config_path .parent() .map(|value| value.join("repos")) .unwrap_or_else(|| config::expand_path("~/.agents/shelf/repos")); let mut imported = Vec::new(); let mut skipped = 0usize; for repo in parsed.repos { let alias = normalize_alias(&repo.alias); let path = shelf_repos_dir.join(&alias); if !path.exists() { skipped += 1; continue; } match set_alias_in_store(store_dir, &alias, &path.display().to_string(), "shelf") { Ok(entry) => imported.push(entry), Err(_) => skipped += 1, } } Ok(RepoAliasImportSummary { imported: imported.len(), skipped, aliases: imported, }) } pub fn resolve_reference_candidates( target_path: &Path, query_text: &str, candidates: &[String], limit: usize, ) -> Result<Vec<RepoCapsuleReference>> { let store_dir = storage_dir(); let registry = load_alias_registry(&store_dir)?; let mut seen_roots = BTreeSet::new(); let mut matches = Vec::new(); for candidate in candidates { if matches.len() >= limit { break; } let Some(root) = resolve_reference_candidate_root(target_path, query_text, candidate, ®istry) else { continue; }; let root_key = root.display().to_string(); if !seen_roots.insert(root_key) { continue; } let capsule = load_or_refresh_capsule_for_root(&store_dir, &root)?; matches.push(RepoCapsuleReference { matched: candidate.clone(), repo_root: capsule.repo_root.clone(), output: render_reference_output(&capsule, candidate), }); } Ok(matches) } fn load_or_refresh_capsule_for_root(store_dir: &Path, root: &Path) -> Result<RepoCapsule> { if let Some(existing) = load_capsule(store_dir, root)? { if capsule_is_fresh(&existing) { return Ok(existing); } } refresh_capsule_for_root(store_dir, root) } fn refresh_capsule_for_root(store_dir: &Path, root: &Path) -> Result<RepoCapsule> { let capsule = build_capsule(root)?; save_capsule(store_dir, &capsule)?; Ok(capsule) } fn resolve_target_path(path: Option<&str>) -> Result<PathBuf> { let base = match path.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => config::expand_path(value), None => std::env::current_dir().context("read current dir")?, }; Ok(base.canonicalize().unwrap_or(base)) } fn resolve_reference_root(path: &Path) -> Result<PathBuf> { let Some(root) = detect_reference_root(path) else { bail!("no repo or flow project found for {}", path.display()); }; Ok(root) } fn resolve_candidate_root(target_path: &Path, candidate: &str) -> Option<PathBuf> { let trimmed = candidate.trim(); if trimmed.is_empty() { return None; } let expanded = if trimmed.starts_with("~/") { config::expand_path(trimmed) } else if Path::new(trimmed).is_absolute() { PathBuf::from(trimmed) } else if trimmed.starts_with("./") || trimmed.starts_with("../") { target_path.join(trimmed) } else { return None; }; if !expanded.exists() { return None; } detect_reference_root(&expanded) } fn resolve_reference_candidate_root( target_path: &Path, query_text: &str, candidate: &str, registry: &RepoAliasRegistry, ) -> Option<PathBuf> { if looks_like_local_path(candidate) { return resolve_candidate_root(target_path, candidate); } let alias = normalize_alias(candidate); let entry = registry.aliases.iter().find(|value| value.alias == alias)?; if !alias_reference_allowed(query_text, &entry.alias) { return None; } let path = PathBuf::from(&entry.path); if !path.exists() { return None; } detect_reference_root(&path) } fn alias_reference_allowed(query_text: &str, alias: &str) -> bool { let normalized_query = query_text.to_ascii_lowercase(); let alias = normalize_alias(alias); if normalized_query.trim() == alias { return true; } let cue_prefixes = [ "see ", "in ", "from ", "using ", "compare ", "inspect ", "study ", "read ", "use ", "open ", ]; cue_prefixes.iter().any(|prefix| { normalized_query.contains(&format!("{prefix}{alias}")) || normalized_query.contains(&format!("{prefix}{alias} ")) || normalized_query.contains(&format!("{prefix}{alias},")) }) } fn normalize_alias(value: &str) -> String { value.trim().to_ascii_lowercase() } fn detect_reference_root(path: &Path) -> Option<PathBuf> { let base = if path.is_dir() { path.to_path_buf() } else { path.parent()?.to_path_buf() }; if let Some(root) = find_git_root(&base) { return Some(root.canonicalize().unwrap_or(root)); } if let Some(flow_toml) = project_snapshot::find_flow_toml_upwards(&base) { let root = flow_toml.parent().unwrap_or(Path::new(".")).to_path_buf(); return Some(root.canonicalize().unwrap_or(root)); } if base.exists() { return Some(base.canonicalize().unwrap_or(base)); } None } fn looks_like_local_path(candidate: &str) -> bool { let trimmed = candidate.trim(); trimmed.starts_with("~/") || trimmed.starts_with('/') || trimmed.starts_with("./") || trimmed.starts_with("../") } fn find_git_root(start: &Path) -> Option<PathBuf> { let mut current = if start.is_dir() { start.to_path_buf() } else { start.parent()?.to_path_buf() }; loop { let dot_git = current.join(".git"); if dot_git.is_dir() || dot_git.is_file() { return Some(current); } if !current.pop() { return None; } } } fn build_capsule(root: &Path) -> Result<RepoCapsule> { let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let repo_root = root.display().to_string(); let repo_name = root .file_name() .and_then(|value| value.to_str()) .unwrap_or("repo") .to_string(); let origin_url = read_origin_url(&root); let repo_id = infer_repo_id(&root, origin_url.as_deref()); let manifests = detect_manifests(&root); let languages = detect_languages(&root, &manifests); let commands = detect_commands(&root, &manifests); let important_paths = detect_important_paths(&root); let docs_hints = detect_docs_hints(&root); let summary = build_summary(&repo_id, &languages, &manifests, &commands, &docs_hints); let watched = collect_watched_stamps(&root); let updated_at_unix = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0); Ok(RepoCapsule { version: CAPSULE_VERSION, repo_root, repo_name, repo_id, origin_url, summary, languages, manifests, commands, important_paths, docs_hints, updated_at_unix, watched, }) } fn build_summary( repo_id: &str, languages: &[String], manifests: &[String], commands: &[String], docs_hints: &[String], ) -> String { let mut parts = vec![repo_id.to_string()]; if !languages.is_empty() { parts.push(format!("languages: {}", languages.join(", "))); } if !manifests.is_empty() { parts.push(format!( "manifests: {}", manifests .iter() .take(4) .cloned() .collect::<Vec<_>>() .join(", ") )); } if !commands.is_empty() { parts.push(format!( "commands: {}", commands .iter() .take(3) .cloned() .collect::<Vec<_>>() .join(", ") )); } if let Some(hint) = docs_hints.first() { parts.push(format!("note: {}", trim_chars(hint, 120))); } trim_chars(&parts.join(" | "), 360) } fn detect_manifests(root: &Path) -> Vec<String> { let candidates = [ "flow.toml", "package.json", "Cargo.toml", "pyproject.toml", "go.mod", "justfile", "Justfile", "Makefile", "flake.nix", "wrangler.toml", "wrangler.json", "wrangler.jsonc", "uv.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock", ]; candidates .into_iter() .filter(|candidate| root.join(candidate).exists()) .map(|value| value.to_string()) .collect() } fn detect_languages(root: &Path, manifests: &[String]) -> Vec<String> { let mut langs = BTreeSet::new(); let manifests_set: BTreeSet<_> = manifests.iter().map(String::as_str).collect(); if manifests_set.contains("Cargo.toml") { langs.insert("Rust".to_string()); } if manifests_set.contains("package.json") || manifests_set.contains("bun.lockb") || manifests_set.contains("bun.lock") || root.join("tsconfig.json").exists() { langs.insert("TypeScript/JavaScript".to_string()); } if manifests_set.contains("pyproject.toml") || manifests_set.contains("uv.lock") { langs.insert("Python".to_string()); } if manifests_set.contains("go.mod") { langs.insert("Go".to_string()); } if manifests_set.contains("flake.nix") { langs.insert("Nix".to_string()); } if root.join("moon.mod.json").exists() { langs.insert("MoonBit".to_string()); } langs.into_iter().collect() } fn detect_commands(root: &Path, manifests: &[String]) -> Vec<String> { let mut commands = Vec::new(); let manifests_set: BTreeSet<_> = manifests.iter().map(String::as_str).collect(); for task in read_flow_task_names(&root.join("flow.toml")) .into_iter() .take(4) { commands.push(format!("f {}", task)); } for script in read_package_scripts(&root.join("package.json")) .into_iter() .take(4) { commands.push(format!("npm run {}", script)); } if manifests_set.contains("Cargo.toml") { commands.push("cargo test".to_string()); commands.push("cargo build".to_string()); } if manifests_set.contains("pyproject.toml") || manifests_set.contains("uv.lock") { commands.push("uv run pytest".to_string()); } if manifests_set.contains("go.mod") { commands.push("go test ./...".to_string()); } if manifests_set.contains("flake.nix") { commands.push("nix develop".to_string()); } dedupe_preserving_order(commands) .into_iter() .take(6) .collect() } fn read_flow_task_names(path: &Path) -> Vec<String> { let Ok(content) = fs::read_to_string(path) else { return Vec::new(); }; let Ok(value) = toml::from_str::<toml::Value>(&content) else { return Vec::new(); }; value .get("tasks") .and_then(|value| value.as_array()) .into_iter() .flatten() .filter_map(|task| { task.get("name") .and_then(|value| value.as_str()) .map(|value| value.to_string()) }) .collect() } fn read_package_scripts(path: &Path) -> Vec<String> { let Ok(content) = fs::read_to_string(path) else { return Vec::new(); }; let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else { return Vec::new(); }; let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) else { return Vec::new(); }; let preferred = ["dev", "start", "test", "build", "lint", "typecheck"]; let mut names = Vec::new(); for name in preferred { if scripts.contains_key(name) { names.push(name.to_string()); } } for name in scripts.keys() { if !names.iter().any(|existing| existing == name) { names.push(name.to_string()); } } names } fn detect_important_paths(root: &Path) -> Vec<String> { let candidates = [ "flow.toml", "README.md", "README.mdx", "AGENTS.md", "agents.md", "package.json", "Cargo.toml", "pyproject.toml", "docs", "src", "apps", "crates", "packages", "workers", ]; candidates .into_iter() .filter(|candidate| root.join(candidate).exists()) .map(|value| value.to_string()) .take(8) .collect() } fn detect_docs_hints(root: &Path) -> Vec<String> { let mut hints = Vec::new(); for (label, path) in [ ("AGENTS", root.join("AGENTS.md")), ("AGENTS", root.join("agents.md")), ("README", root.join("README.md")), ("README", root.join("README.mdx")), ("README", root.join("readme.md")), ("README", root.join("readme.mdx")), ] { if let Some(hint) = read_text_hint(&path, label) { hints.push(hint); break; } } if let Some(hint) = read_docs_index_hint(&root.join("docs")) { hints.push(hint); } hints.into_iter().take(3).collect() } fn read_text_hint(path: &Path, label: &str) -> Option<String> { let content = fs::read_to_string(path).ok()?; let mut lines = Vec::new(); let mut in_code_block = false; for raw_line in content.lines() { let line = raw_line.trim(); if line.starts_with("```") { in_code_block = !in_code_block; continue; } if in_code_block || line.is_empty() { continue; } if matches!(line, "---" | "+++") || line.starts_with("title:") { continue; } let normalized = line.trim_start_matches('#').trim_start_matches('-').trim(); if normalized.is_empty() || normalized.starts_with('<') || normalized.starts_with('[') || normalized.eq_ignore_ascii_case("instructions") { continue; } lines.push(normalized.to_string()); if lines.len() >= 3 { break; } } if lines.is_empty() { None } else { Some(format!("{label}: {}", trim_chars(&lines.join(" "), 220))) } } fn read_docs_index_hint(docs_dir: &Path) -> Option<String> { if !docs_dir.is_dir() { return None; } let mut names = fs::read_dir(docs_dir) .ok()? .flatten() .filter_map(|entry| { let path = entry.path(); let ext = path.extension()?.to_str()?; if !matches!(ext, "md" | "mdx") { return None; } path.file_name() .and_then(|value| value.to_str()) .map(|value| value.to_string()) }) .collect::<Vec<_>>(); names.sort(); names.truncate(5); if names.is_empty() { None } else { Some(format!("Docs: {}", names.join(", "))) } } fn collect_watched_stamps(root: &Path) -> Vec<PathStamp> { watched_paths(root) .into_iter() .map(|path| stamp_path(&path)) .collect() } fn watched_paths(root: &Path) -> Vec<PathBuf> { let mut paths = vec![root.to_path_buf()]; for candidate in [ "flow.toml", "README.md", "README.mdx", "readme.md", "readme.mdx", "AGENTS.md", "agents.md", "package.json", "Cargo.toml", "pyproject.toml", "go.mod", "justfile", "Justfile", "Makefile", "flake.nix", "docs/README.md", "docs/index.md", "docs/index.mdx", ] { paths.push(root.join(candidate)); } paths } fn stamp_path(path: &Path) -> PathStamp { let metadata = fs::metadata(path).ok(); let exists = metadata.is_some(); let (len, modified_sec, modified_nsec) = metadata .and_then(|meta| { let modified = meta.modified().ok()?; let duration = modified.duration_since(UNIX_EPOCH).ok()?; Some((meta.len(), duration.as_secs(), duration.subsec_nanos())) }) .unwrap_or((0, 0, 0)); PathStamp { path: path.display().to_string(), exists, len, modified_sec, modified_nsec, } } fn capsule_is_fresh(capsule: &RepoCapsule) -> bool { capsule .watched .iter() .all(|stamp| stamp_path(Path::new(&stamp.path)) == *stamp) } fn render_reference_output(capsule: &RepoCapsule, matched: &str) -> String { let mut lines = vec![format!("Repo reference: {}", matched)]; lines.push(format!("- Repo: {}", capsule.repo_id)); lines.push(format!("- Root: {}", capsule.repo_root)); if let Some(origin) = capsule.origin_url.as_deref() { lines.push(format!("- Remote: {}", origin)); } if !capsule.languages.is_empty() { lines.push(format!("- Languages: {}", capsule.languages.join(", "))); } if !capsule.commands.is_empty() { lines.push(format!( "- Common commands: {}", capsule .commands .iter() .take(4) .cloned() .collect::<Vec<_>>() .join(", ") )); } if !capsule.important_paths.is_empty() { lines.push(format!( "- Important paths: {}", capsule .important_paths .iter() .take(5) .cloned() .collect::<Vec<_>>() .join(", ") )); } for hint in capsule.docs_hints.iter().take(2) { lines.push(format!("- {}", hint)); } lines.join("\n") } fn render_capsule_report(capsule: &RepoCapsule) -> String { let mut out = String::new(); out.push_str(&format!("Repo capsule: {}\n", capsule.repo_id)); out.push_str(&format!("root: {}\n", capsule.repo_root)); if let Some(origin) = capsule.origin_url.as_deref() { out.push_str(&format!("origin: {}\n", origin)); } out.push_str(&format!("summary: {}\n", capsule.summary)); if !capsule.languages.is_empty() { out.push_str(&format!("languages: {}\n", capsule.languages.join(", "))); } if !capsule.manifests.is_empty() { out.push_str(&format!("manifests: {}\n", capsule.manifests.join(", "))); } if !capsule.commands.is_empty() { out.push_str(&format!("commands: {}\n", capsule.commands.join(", "))); } if !capsule.important_paths.is_empty() { out.push_str("important_paths:\n"); for path in &capsule.important_paths { out.push_str(&format!("- {}\n", path)); } } if !capsule.docs_hints.is_empty() { out.push_str("notes:\n"); for hint in &capsule.docs_hints { out.push_str(&format!("- {}\n", hint)); } } out } fn infer_repo_id(root: &Path, origin_url: Option<&str>) -> String { if let Some(origin) = origin_url && let Some(id) = parse_repo_id_from_remote(origin) { return id; } let name = root .file_name() .and_then(|value| value.to_str()) .unwrap_or("repo"); if let Some(parent) = root .parent() .and_then(|value| value.file_name()) .and_then(|value| value.to_str()) { return format!("{}/{}", parent, name); } name.to_string() } fn parse_repo_id_from_remote(remote: &str) -> Option<String> { let trimmed = remote.trim().trim_end_matches(".git"); if let Some(rest) = trimmed.strip_prefix("git@github.com:") { return Some(rest.to_string()); } if let Some(rest) = trimmed.strip_prefix("https://github.com/") { return Some(rest.to_string()); } if let Some(rest) = trimmed.strip_prefix("ssh://git@github.com/") { return Some(rest.to_string()); } None } fn read_origin_url(root: &Path) -> Option<String> { let git_dir = resolve_git_dir(root)?; let common_dir = resolve_common_git_dir(&git_dir); let config_path = common_dir.join("config"); parse_git_remote_url(&config_path, "origin") } fn resolve_git_dir(root: &Path) -> Option<PathBuf> { let dot_git = root.join(".git"); if dot_git.is_dir() { return Some(dot_git); } let content = fs::read_to_string(&dot_git).ok()?; let gitdir = content.strip_prefix("gitdir:")?.trim(); let path = PathBuf::from(gitdir); let resolved = if path.is_absolute() { path } else { dot_git.parent()?.join(path) }; Some(resolved.canonicalize().unwrap_or(resolved)) } fn resolve_common_git_dir(git_dir: &Path) -> PathBuf { let commondir = git_dir.join("commondir"); let Ok(content) = fs::read_to_string(&commondir) else { return git_dir.to_path_buf(); }; let trimmed = content.trim(); if trimmed.is_empty() { return git_dir.to_path_buf(); } let path = PathBuf::from(trimmed); let resolved = if path.is_absolute() { path } else { git_dir.join(path) }; resolved.canonicalize().unwrap_or(resolved) } fn parse_git_remote_url(config_path: &Path, remote_name: &str) -> Option<String> { let content = fs::read_to_string(config_path).ok()?; let mut in_remote = false; for raw_line in content.lines() { let line = raw_line.trim(); if line.starts_with('[') && line.ends_with(']') { in_remote = parse_remote_section(line) .is_some_and(|value| value.eq_ignore_ascii_case(remote_name)); continue; } if !in_remote { continue; } let Some((key, value)) = line.split_once('=') else { continue; }; if key.trim().eq_ignore_ascii_case("url") { return Some(value.trim().to_string()); } } None } fn parse_remote_section(section: &str) -> Option<String> { let inner = section.strip_prefix('[')?.strip_suffix(']')?.trim(); let rest = inner.strip_prefix("remote")?.trim(); let name = rest.strip_prefix('"')?.strip_suffix('"')?.trim(); if name.is_empty() { None } else { Some(name.to_string()) } } fn dedupe_preserving_order(values: Vec<String>) -> Vec<String> { let mut seen = BTreeSet::new(); let mut deduped = Vec::new(); for value in values { if seen.insert(value.clone()) { deduped.push(value); } } deduped } fn trim_chars(value: &str, limit: usize) -> String { if value.chars().count() <= limit { return value.to_string(); } let keep = limit.saturating_sub(3); value.chars().take(keep).collect::<String>() + "..." } fn storage_dir() -> PathBuf { if let Ok(path) = std::env::var(STORE_DIR_ENV) { return config::expand_path(&path); } config::expand_path(DEFAULT_STORE_DIR) } fn registry_path(store_dir: &Path) -> PathBuf { store_dir.join(REGISTRY_FILE) } fn load_alias_registry(store_dir: &Path) -> Result<RepoAliasRegistry> { let path = registry_path(store_dir); if !path.exists() { return Ok(RepoAliasRegistry { version: 1, aliases: Vec::new(), }); } let payload = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; serde_json::from_str::<RepoAliasRegistry>(&payload) .with_context(|| format!("parse {}", path.display())) } fn save_alias_registry(store_dir: &Path, registry: &RepoAliasRegistry) -> Result<()> { fs::create_dir_all(store_dir) .with_context(|| format!("create store dir {}", store_dir.display()))?; let path = registry_path(store_dir); let payload = serde_json::to_string_pretty(registry)?; fs::write(&path, payload).with_context(|| format!("write {}", path.display())) } fn now_unix() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|value| value.as_secs()) .unwrap_or(0) } fn save_capsule(store_dir: &Path, capsule: &RepoCapsule) -> Result<()> { fs::create_dir_all(store_dir) .with_context(|| format!("create store dir {}", store_dir.display()))?; let path = capsule_path(store_dir, &capsule.repo_root); let payload = serde_json::to_string_pretty(capsule)?; fs::write(&path, payload).with_context(|| format!("write {}", path.display())) } fn load_capsule(store_dir: &Path, root: &Path) -> Result<Option<RepoCapsule>> { if !store_dir.exists() { return Ok(None); } let path = capsule_path(store_dir, &root.display().to_string()); if !path.exists() { return Ok(None); } let payload = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let capsule = serde_json::from_str::<RepoCapsule>(&payload) .with_context(|| format!("parse {}", path.display()))?; Ok(Some(capsule)) } fn capsule_path(store_dir: &Path, repo_root: &str) -> PathBuf { let hash = blake3::hash(repo_root.as_bytes()).to_hex().to_string(); store_dir.join(format!("{hash}.json")) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn build_capsule_captures_repo_shape() { let dir = tempdir().expect("tempdir"); let root = dir.path().join("owner").join("repo"); fs::create_dir_all(root.join(".git")).expect("create .git"); fs::write( root.join("README.md"), "# Repo\n\nFast TypeScript service\n", ) .expect("write readme"); fs::write( root.join("AGENTS.md"), "Use flow tasks first.\nKeep changes small.\n", ) .expect("write agents"); fs::write( root.join("flow.toml"), "[[tasks]]\nname = \"dev\"\ncommand = \"bun run dev\"\n\n[[tasks]]\nname = \"test\"\ncommand = \"bun test\"\n", ) .expect("write flow"); fs::write( root.join("package.json"), r#"{"name":"repo","scripts":{"dev":"vite","test":"vitest","build":"tsc -b"}}"#, ) .expect("write package"); let capsule = build_capsule(&root).expect("build capsule"); assert_eq!(capsule.repo_id, "owner/repo"); assert!( capsule .languages .iter() .any(|value| value == "TypeScript/JavaScript") ); assert!(capsule.commands.iter().any(|value| value == "f dev")); assert!(capsule.commands.iter().any(|value| value == "npm run test")); assert!( capsule .docs_hints .iter() .any(|value| value.starts_with("AGENTS:")) ); } #[test] fn load_or_refresh_capsule_reuses_fresh_store() { let dir = tempdir().expect("tempdir"); let store = dir.path().join("store"); let root = dir.path().join("repo"); fs::create_dir_all(root.join(".git")).expect("create .git"); fs::write(root.join("README.md"), "# Repo\n\nhello\n").expect("write readme"); let first = load_or_refresh_capsule_for_root(&store, &root).expect("first load"); let second = load_or_refresh_capsule_for_root(&store, &root).expect("second load"); assert_eq!(first.repo_root, second.repo_root); assert_eq!(first.updated_at_unix, second.updated_at_unix); } #[test] fn resolve_reference_candidates_finds_repo_paths() { let dir = tempdir().expect("tempdir"); let store = dir.path().join("store"); let target = dir.path().join("target"); let repo = dir.path().join("external"); fs::create_dir_all(&target).expect("create target"); fs::create_dir_all(repo.join(".git")).expect("create .git"); fs::write(repo.join("README.md"), "# External\n\ncompare this repo\n") .expect("write readme"); let candidates = vec![repo.display().to_string()]; let matches = resolve_reference_candidates_with_store( &store, &target, "see external and compare", &candidates, 2, ) .expect("resolve refs"); assert_eq!(matches.len(), 1); assert!(matches[0].output.contains("Repo reference:")); } #[test] fn resolve_reference_candidates_finds_registered_aliases() { let dir = tempdir().expect("tempdir"); let store = dir.path().join("store"); let target = dir.path().join("target"); let repo = dir.path().join("Effect-TS").join("effect-smol"); fs::create_dir_all(&target).expect("create target"); fs::create_dir_all(repo.join(".git")).expect("create .git"); fs::write(repo.join("README.md"), "# effect-smol\n\nsmall repo\n").expect("write readme"); let entry = RepoAliasEntry { alias: "effect-smol".to_string(), path: repo.display().to_string(), source: "manual".to_string(), updated_at_unix: 1, }; save_alias_registry( &store, &RepoAliasRegistry { version: 1, aliases: vec![entry], }, ) .expect("save registry"); let candidates = vec!["effect-smol".to_string()]; let matches = resolve_reference_candidates_with_store( &store, &target, "see effect-smol and compare architecture", &candidates, 2, ) .expect("resolve alias refs"); assert_eq!(matches.len(), 1); assert!(matches[0].output.contains("effect-smol")); } #[test] fn import_shelf_aliases_loads_sibling_repos_dir() { let dir = tempdir().expect("tempdir"); let store = dir.path().join("store"); let shelf = dir.path().join("shelf"); let repos_dir = shelf.join("repos"); let repo = repos_dir.join("effect-smol"); fs::create_dir_all(repo.join(".git")).expect("create .git"); fs::write(repo.join("README.md"), "# effect-smol\n\nsmall repo\n").expect("write readme"); fs::create_dir_all(&shelf).expect("create shelf"); fs::write( shelf.join("config.json"), r#"{"version":1,"syncIntervalMinutes":60,"repos":[{"alias":"effect-smol"}]}"#, ) .expect("write shelf config"); let summary = import_shelf_aliases_into_store(&store, &shelf.join("config.json")).expect("import"); assert_eq!(summary.imported, 1); assert_eq!(summary.skipped, 0); let aliases = load_alias_registry(&store).expect("load registry").aliases; assert!(aliases.iter().any(|entry| entry.alias == "effect-smol")); } fn resolve_reference_candidates_with_store( store: &Path, target_path: &Path, query_text: &str, candidates: &[String], limit: usize, ) -> Result<Vec<RepoCapsuleReference>> { let registry = load_alias_registry(store)?; let mut seen_roots = BTreeSet::new(); let mut matches = Vec::new(); for candidate in candidates { if matches.len() >= limit { break; } let Some(root) = resolve_reference_candidate_root(target_path, query_text, candidate, ®istry) else { continue; }; if !seen_roots.insert(root.display().to_string()) { continue; } let capsule = load_or_refresh_capsule_for_root(store, &root)?; matches.push(RepoCapsuleReference { matched: candidate.clone(), repo_root: capsule.repo_root.clone(), output: render_reference_output(&capsule, candidate), }); } Ok(matches) } } ================================================ FILE: src/repos.rs ================================================ //! Repository management commands. //! //! Supports cloning repos into a structured local directory. use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use serde::Deserialize; use url::Url; use crate::cli::{CloneOpts, ReposAction, ReposCloneOpts, ReposCommand}; use crate::{config, publish, repo_capsule, ssh, ssh_keys, upstream, vcs}; const DEFAULT_REPOS_ROOT: &str = "~/repos"; const REPOS_ROOT_OVERRIDE_ENV: &str = "FLOW_REPOS_ALLOW_ROOT_OVERRIDE"; /// Run the repos subcommand. pub fn run(cmd: ReposCommand) -> Result<()> { match cmd.action { Some(ReposAction::Clone(opts)) => { let path = clone_repo(opts)?; open_in_zed(&path)?; Ok(()) } Some(ReposAction::Create(opts)) => publish::run_github(opts), Some(ReposAction::Capsule(opts)) => repo_capsule::run_capsule(opts), Some(ReposAction::Alias(cmd)) => repo_capsule::run_alias(cmd), None => fuzzy_select_repo(), } } /// Clone into the current working directory (git clone style destination behavior). pub fn clone_git_like(opts: CloneOpts) -> Result<()> { ssh::ensure_ssh_env(); let mode = ssh::ssh_mode(); if matches!(mode, ssh::SshMode::Force) && !ssh::has_identities() { match ssh_keys::ensure_default_identity(24) { Ok(()) => {} Err(err) => { bail!( "SSH mode is forced but no key is available. Run `f ssh setup` or `f ssh unlock` (error: {})", err ); } } } let clone_url = resolve_git_like_clone_url(&opts.url)?; let mut cmd = Command::new("git"); cmd.arg("clone").arg(&clone_url); if let Some(dir) = opts.directory { cmd.arg(dir); } let status = cmd .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run git clone")?; if !status.success() { bail!("git clone failed"); } Ok(()) } fn open_in_zed(path: &std::path::Path) -> Result<()> { std::process::Command::new("open") .args(["-a", "/Applications/Zed Preview.app"]) .arg(path) .status() .context("failed to open Zed")?; Ok(()) } /// Fuzzy search through repos in ~/repos and print the selected path. fn fuzzy_select_repo() -> Result<()> { let root = config::expand_path(DEFAULT_REPOS_ROOT); if !root.exists() { println!("No repos directory found at {}", root.display()); println!("Clone a repo with: f repos clone <url>"); return Ok(()); } let repos = discover_repos(&root)?; if repos.is_empty() { println!("No repositories found in {}", root.display()); println!("Clone a repo with: f repos clone <url>"); return Ok(()); } if which::which("fzf").is_err() { println!("fzf not found on PATH – install it to use fuzzy selection."); println!("Available repositories:"); for repo in &repos { println!(" {}", repo.display); } return Ok(()); } if let Some(selected) = run_fzf(&repos)? { open_in_zed(&selected.path)?; } Ok(()) } struct RepoEntry { display: String, path: PathBuf, } /// Discover all repos in the root directory (owner/repo structure). fn discover_repos(root: &Path) -> Result<Vec<RepoEntry>> { let mut repos = Vec::new(); let owners = match fs::read_dir(root) { Ok(entries) => entries, Err(_) => return Ok(repos), }; for owner_entry in owners.flatten() { let owner_path = owner_entry.path(); if !owner_path.is_dir() { continue; } let owner_name = match owner_path.file_name() { Some(name) => name.to_string_lossy().to_string(), None => continue, }; // Skip hidden directories if owner_name.starts_with('.') { continue; } let repo_entries = match fs::read_dir(&owner_path) { Ok(entries) => entries, Err(_) => continue, }; for repo_entry in repo_entries.flatten() { let repo_path = repo_entry.path(); if !repo_path.is_dir() { continue; } let repo_name = match repo_path.file_name() { Some(name) => name.to_string_lossy().to_string(), None => continue, }; // Skip hidden directories if repo_name.starts_with('.') { continue; } // Check if it's a git repo if repo_path.join(".git").exists() { repos.push(RepoEntry { display: format!("{}/{}", owner_name, repo_name), path: repo_path, }); } } } repos.sort_by(|a, b| a.display.cmp(&b.display)); Ok(repos) } fn run_fzf(entries: &[RepoEntry]) -> Result<Option<&RepoEntry>> { let mut child = Command::new("fzf") .arg("--prompt") .arg("repo> ") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; { let stdin = child.stdin.as_mut().context("failed to open fzf stdin")?; for entry in entries { writeln!(stdin, "{}", entry.display)?; } } let output = child.wait_with_output()?; if !output.status.success() { return Ok(None); } let selection = String::from_utf8(output.stdout).context("fzf output was not valid UTF-8")?; let selection = selection.trim(); if selection.is_empty() { return Ok(None); } Ok(entries.iter().find(|e| e.display == selection)) } #[derive(Debug, Clone)] pub(crate) struct RepoRef { pub(crate) owner: String, pub(crate) repo: String, } #[derive(Debug, Deserialize)] struct RepoInfo { fork: bool, parent: Option<RepoParent>, source: Option<RepoParent>, } #[derive(Debug, Deserialize)] struct RepoParent { #[serde(rename = "ssh_url")] ssh_url: String, #[serde(default)] clone_url: Option<String>, } #[derive(Debug)] enum RepoTarget { GitHub(RepoRef), Generic(GenericRepoRef), } #[derive(Debug)] struct GenericRepoRef { path: Vec<String>, clone_url: String, } pub(crate) fn clone_repo(opts: ReposCloneOpts) -> Result<PathBuf> { ssh::ensure_ssh_env(); let mode = ssh::ssh_mode(); if matches!(mode, ssh::SshMode::Force) && !ssh::has_identities() { match ssh_keys::ensure_default_identity(24) { Ok(()) => {} Err(err) => { bail!( "SSH mode is forced but no key is available. Run `f ssh setup` or `f ssh unlock` (error: {})", err ); } } } // Always prefer SSH for GitHub clone/upstream URLs. let prefer_ssh = true; let repo_target = parse_repo_target(&opts.url)?; let root = normalize_root(&opts.root)?; let mut github_ref: Option<RepoRef> = None; let (target_dir, clone_url, is_github) = match repo_target { RepoTarget::GitHub(repo_ref) => { github_ref = Some(RepoRef { owner: repo_ref.owner.clone(), repo: repo_ref.repo.clone(), }); let owner_dir = root.join(&repo_ref.owner); let target_dir = owner_dir.join(&repo_ref.repo); let clone_url = if prefer_ssh { format!("git@github.com:{}/{}.git", repo_ref.owner, repo_ref.repo) } else { format!( "https://github.com/{}/{}.git", repo_ref.owner, repo_ref.repo ) }; (target_dir, clone_url, true) } RepoTarget::Generic(repo_ref) => { let mut target_dir = root.to_path_buf(); let path_len = repo_ref.path.len(); let parts = if path_len >= 2 { &repo_ref.path[path_len - 2..] } else { repo_ref.path.as_slice() }; for part in parts { target_dir = target_dir.join(part); } (target_dir, repo_ref.clone_url, false) } }; if preflight_clone_target(&target_dir)? { println!("Already cloned: {}", target_dir.display()); return Ok(target_dir); } if let Some(parent) = target_dir.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let shallow = !opts.full; let fetch_depth = if shallow { Some(1) } else { None }; run_git_clone(&clone_url, &target_dir, shallow)?; println!("✓ cloned to {}", target_dir.display()); if opts.no_upstream { if shallow { spawn_background_history_fetch(&target_dir, false)?; } init_jj_repo(&target_dir)?; return Ok(target_dir); } if !is_github { let upstream_url = opts .upstream_url .clone() .unwrap_or_else(|| clone_url.clone()); let upstream_is_origin = upstream_url.trim() == clone_url.as_str(); if upstream_is_origin { println!("No upstream provided; using origin as upstream."); } configure_upstream(&target_dir, &upstream_url, fetch_depth)?; if shallow { spawn_background_history_fetch(&target_dir, !upstream_is_origin)?; } init_jj_repo(&target_dir)?; return Ok(target_dir); } let upstream_url = if let Some(url) = opts.upstream_url { Some(url) } else { let repo_ref = github_ref .as_ref() .ok_or_else(|| anyhow::anyhow!("missing GitHub repo reference"))?; resolve_upstream_url(repo_ref, prefer_ssh)? }; let (upstream_url, upstream_is_origin) = match upstream_url { Some(url) => { let is_origin = url.trim() == clone_url.as_str(); (url, is_origin) } None => { println!("No fork detected; using origin as upstream."); (clone_url.clone(), true) } }; configure_upstream(&target_dir, &upstream_url, fetch_depth)?; if shallow { spawn_background_history_fetch(&target_dir, !upstream_is_origin)?; } init_jj_repo(&target_dir)?; Ok(target_dir) } fn preflight_clone_target(target_dir: &Path) -> Result<bool> { match clone_target_state(target_dir)? { CloneTargetState::Missing | CloneTargetState::EmptyDir => Ok(false), CloneTargetState::GitCheckout => Ok(true), CloneTargetState::OccupiedNonRepo => bail!( "target path exists but is not a git checkout: {}", target_dir.display() ), } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CloneTargetState { Missing, EmptyDir, GitCheckout, OccupiedNonRepo, } fn clone_target_state(path: &Path) -> Result<CloneTargetState> { if !path.exists() { return Ok(CloneTargetState::Missing); } if path.join(".git").exists() { return Ok(CloneTargetState::GitCheckout); } if !path.is_dir() { return Ok(CloneTargetState::OccupiedNonRepo); } let mut entries = fs::read_dir(path).with_context(|| format!("failed to inspect {}", path.display()))?; if entries.next().is_none() { return Ok(CloneTargetState::EmptyDir); } Ok(CloneTargetState::OccupiedNonRepo) } fn init_jj_repo(repo_dir: &Path) -> Result<()> { if repo_dir.join(".jj").exists() { return Ok(()); } if vcs::ensure_jj_installed().is_err() { println!("⚠ jj not found; skipping jj init"); return Ok(()); } let has_git = repo_dir.join(".git").exists(); let mut cmd = Command::new("jj"); cmd.current_dir(repo_dir).arg("git").arg("init"); if has_git { cmd.arg("--colocate"); } let status = cmd.status().context("failed to run jj git init")?; if !status.success() { println!("⚠ jj git init failed; continuing"); return Ok(()); } let _ = Command::new("jj") .current_dir(repo_dir) .args(["git", "fetch"]) .status(); if jj_auto_track(repo_dir) { let branch = jj_default_branch(repo_dir); let remote = jj_default_remote(repo_dir); let track_ref = format!("{}@{}", branch, remote); let _ = Command::new("jj") .current_dir(repo_dir) .args(["bookmark", "track", &track_ref]) .status(); } println!("✓ jj initialized for {}", repo_dir.display()); Ok(()) } fn jj_default_remote(repo_dir: &Path) -> String { if let Some(cfg) = load_jj_config(repo_dir) { if let Some(remote) = cfg.remote { return remote; } } "origin".to_string() } fn jj_auto_track(repo_dir: &Path) -> bool { load_jj_config(repo_dir) .and_then(|cfg| cfg.auto_track) .unwrap_or(true) } fn jj_default_branch(repo_dir: &Path) -> String { if let Some(cfg) = load_jj_config(repo_dir) { if let Some(branch) = cfg.default_branch { return branch; } } if git_ref_exists(repo_dir, "refs/remotes/origin/main") || git_ref_exists(repo_dir, "refs/heads/main") { return "main".to_string(); } if git_ref_exists(repo_dir, "refs/remotes/origin/master") || git_ref_exists(repo_dir, "refs/heads/master") { return "master".to_string(); } "main".to_string() } fn git_ref_exists(repo_dir: &Path, reference: &str) -> bool { Command::new("git") .current_dir(repo_dir) .args(["rev-parse", "--verify", reference]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } fn load_jj_config(repo_dir: &Path) -> Option<config::JjConfig> { let local = repo_dir.join("flow.toml"); if local.exists() { if let Ok(cfg) = config::load(&local) { if cfg.jj.is_some() { return cfg.jj; } } } let global = config::default_config_path(); if global.exists() { if let Ok(cfg) = config::load(&global) { if cfg.jj.is_some() { return cfg.jj; } } } None } fn parse_repo_target(input: &str) -> Result<RepoTarget> { if is_github_input(input) { return parse_github_repo(input).map(RepoTarget::GitHub); } let generic = parse_generic_repo(input)?; Ok(RepoTarget::Generic(generic)) } fn resolve_git_like_clone_url(input: &str) -> Result<String> { let trimmed = input.trim(); if trimmed.is_empty() { bail!("missing repository URL"); } if trimmed.starts_with("git@github.com:") || trimmed.contains("github.com/") || looks_like_github_shorthand(trimmed) { let repo_ref = parse_github_repo(trimmed)?; return Ok(format!( "git@github.com:{}/{}.git", repo_ref.owner, repo_ref.repo )); } Ok(trimmed.to_string()) } fn looks_like_github_shorthand(input: &str) -> bool { if input.contains("://") || input.contains('@') || input.starts_with('/') || input.starts_with("./") || input.starts_with("../") || input.starts_with("~/") { return false; } let mut parts = input.split('/'); let Some(owner) = parts.next() else { return false; }; let Some(repo) = parts.next() else { return false; }; if parts.next().is_some() { return false; } if owner.is_empty() || repo.is_empty() { return false; } owner != "." && owner != ".." && repo != "." && repo != ".." } fn is_github_input(input: &str) -> bool { let trimmed = input.trim(); if trimmed.starts_with("git@github.com:") || trimmed.contains("github.com/") { return true; } !trimmed.contains("://") && !trimmed.contains('@') } fn parse_generic_repo(input: &str) -> Result<GenericRepoRef> { let trimmed = input.trim(); if trimmed.is_empty() { bail!("missing repository URL"); } if let Ok(url) = Url::parse(trimmed) { let path = url .path() .trim_matches('/') .split('/') .filter(|p| !p.is_empty()) .map(|p| p.trim_end_matches(".git").to_string()) .collect::<Vec<_>>(); if path.is_empty() { bail!("unable to parse repository path from: {}", input); } return Ok(GenericRepoRef { path, clone_url: trimmed.to_string(), }); } if let Some(at) = trimmed.find('@') { if let Some(colon) = trimmed[at + 1..].find(':') { let rest = &trimmed[at + 1 + colon + 1..]; let path = rest .trim_matches('/') .split('/') .filter(|p| !p.is_empty()) .map(|p| p.trim_end_matches(".git").to_string()) .collect::<Vec<_>>(); if path.is_empty() { bail!("unable to parse repository from: {}", input); } return Ok(GenericRepoRef { path, clone_url: trimmed.to_string(), }); } } bail!("unable to parse repository URL: {}", input) } pub(crate) fn parse_github_repo(input: &str) -> Result<RepoRef> { let trimmed = input.trim(); if trimmed.is_empty() { bail!("missing repository URL"); } let path = if let Some(rest) = trimmed.strip_prefix("git@github.com:") { rest } else if let Some(idx) = trimmed.find("github.com/") { &trimmed[idx + "github.com/".len()..] } else { trimmed }; let path = path .trim_start_matches('/') .split(&['?', '#'][..]) .next() .unwrap_or(path) .trim_end_matches('/'); let mut parts = path.split('/'); let owner = parts.next().unwrap_or("").trim(); let repo = parts.next().unwrap_or("").trim(); if owner.is_empty() || repo.is_empty() { bail!("unable to parse GitHub repo from: {}", input); } let repo = repo.strip_suffix(".git").unwrap_or(repo); if repo.is_empty() { bail!("unable to parse GitHub repo from: {}", input); } Ok(RepoRef { owner: owner.to_string(), repo: repo.to_string(), }) } pub(crate) fn normalize_root(raw: &str) -> Result<PathBuf> { let expanded = config::expand_path(raw); let cwd = std::env::current_dir().context("failed to resolve current directory")?; let root = if expanded.is_absolute() { expanded } else { cwd.join(expanded) }; let default_root = config::expand_path(DEFAULT_REPOS_ROOT); if root != default_root && !repos_root_override_enabled() { bail!( "repos root is immutable; use {} or set {}=1 to override", default_root.display(), REPOS_ROOT_OVERRIDE_ENV ); } Ok(root) } fn repos_root_override_enabled() -> bool { match std::env::var(REPOS_ROOT_OVERRIDE_ENV) { Ok(value) => { let trimmed = value.trim(); !trimmed.is_empty() && trimmed != "0" } Err(_) => false, } } fn run_git_clone(url: &str, target_dir: &Path, shallow: bool) -> Result<()> { let mut cmd = Command::new("git"); cmd.arg("clone"); if shallow { cmd.args(["--depth", "1"]); } let status = cmd .arg(url) .arg(target_dir) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run git clone")?; if !status.success() { bail!("git clone failed"); } Ok(()) } fn resolve_upstream_url(repo_ref: &RepoRef, prefer_ssh: bool) -> Result<Option<String>> { let output = match Command::new("gh") .args([ "api", &format!("repos/{}/{}", repo_ref.owner, repo_ref.repo), ]) .output() { Ok(output) => output, Err(err) => { println!("gh not available; skipping upstream auto-setup ({})", err); return Ok(None); } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let message = stderr.trim(); if message.is_empty() { println!("gh api failed; skipping upstream auto-setup"); } else { println!("gh api failed; skipping upstream auto-setup: {}", message); } println!("Authenticate with: gh auth login"); return Ok(None); } let info: RepoInfo = serde_json::from_slice(&output.stdout).context("failed to parse gh api response")?; if !info.fork { return Ok(None); } let parent = info.parent.or(info.source).map(|parent| { if prefer_ssh { parent.ssh_url } else { parent.clone_url.unwrap_or_else(|| parent.ssh_url) } }); Ok(parent) } fn configure_upstream(repo_dir: &Path, upstream_url: &str, depth: Option<u32>) -> Result<()> { println!("Setting up upstream: {}", upstream_url); let cwd = std::env::current_dir().context("failed to capture current directory")?; std::env::set_current_dir(repo_dir) .with_context(|| format!("failed to enter {}", repo_dir.display()))?; let result = upstream::setup_upstream_with_depth(Some(upstream_url), None, depth); if let Err(err) = std::env::set_current_dir(&cwd) { println!("warning: failed to restore working directory: {}", err); } result } fn spawn_background_history_fetch(repo_dir: &Path, has_upstream: bool) -> Result<()> { let mut command = String::from("git fetch --unshallow --tags origin"); if has_upstream { command.push_str(" && git fetch --tags upstream"); } let _child = Command::new("sh") .arg("-c") .arg(command) .current_dir(repo_dir) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .context("failed to spawn background history fetch")?; println!("Fetching full history in background..."); Ok(()) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn preflight_clone_target_detects_git_checkout() { let dir = tempdir().expect("tempdir"); fs::create_dir_all(dir.path().join(".git")).expect("git dir"); let already_cloned = preflight_clone_target(dir.path()).expect("preflight"); assert!(already_cloned); } #[test] fn preflight_clone_target_allows_empty_dir() { let dir = tempdir().expect("tempdir"); let already_cloned = preflight_clone_target(dir.path()).expect("preflight"); assert!(!already_cloned); } #[test] fn preflight_clone_target_rejects_non_repo_dir() { let dir = tempdir().expect("tempdir"); fs::create_dir_all(dir.path().join("user_files")).expect("user_files dir"); let err = preflight_clone_target(dir.path()).expect_err("expected non-repo error"); assert!( err.to_string() .contains("target path exists but is not a git checkout") ); } } ================================================ FILE: src/reviews_todo.rs ================================================ use std::path::Path; use std::process::Command; use anyhow::{Result, bail}; use chrono::Utc; use crate::cli::{CommitQueueAction, CommitQueueCommand, ReviewsTodoAction, ReviewsTodoCommand}; use crate::commit; use crate::todo; pub fn run(cmd: ReviewsTodoCommand) -> Result<()> { let action = cmd.action.unwrap_or(ReviewsTodoAction::List); match action { ReviewsTodoAction::List => list_review_todos(), ReviewsTodoAction::Show { id } => show_review_todo(&id), ReviewsTodoAction::Done { id } => done_review_todo(&id), ReviewsTodoAction::Fix { id, all } => fix_review_todos(id.as_deref(), all), ReviewsTodoAction::Codex { hashes, all } => commit::run_commit_queue(CommitQueueCommand { action: Some(CommitQueueAction::Review { hashes, all }), }), ReviewsTodoAction::ApproveAll { force, allow_issues, allow_unreviewed, } => commit::run_commit_queue(CommitQueueCommand { action: Some(CommitQueueAction::ApproveAll { force, allow_issues, allow_unreviewed, }), }), } } fn list_review_todos() -> Result<()> { let root = todo::project_root(); let items = todo::load_review_todos(&root)?; if items.is_empty() { println!("No review todos."); return Ok(()); } let mut open_items: Vec<_> = items .iter() .filter(|item| item.status != "completed") .collect(); if open_items.is_empty() { println!("All review todos resolved."); return Ok(()); } // Sort by priority (P1 first) open_items.sort_by(|a, b| { let pa = a.priority.as_deref().unwrap_or("P4"); let pb = b.priority.as_deref().unwrap_or("P4"); pa.cmp(pb) }); let (p1, p2, p3, p4, total) = todo::count_open_review_todos_by_priority(&root)?; println!( "Review todos: {} open (P1:{} P2:{} P3:{} P4:{})\n", total, p1, p2, p3, p4 ); for item in &open_items { let priority = item.priority.as_deref().unwrap_or("P4"); let indicator = match priority { "P1" => "[P1 !!]", "P2" => "[P2 ! ]", "P3" => "[P3 ]", _ => "[P4 ]", }; let short_id = &item.id[..item.id.len().min(8)]; println!("{} {} {}", indicator, short_id, item.title); } Ok(()) } fn show_review_todo(id: &str) -> Result<()> { let root = todo::project_root(); let (_, items) = todo::load_items_at_root(&root)?; let review_items: Vec<_> = items .iter() .filter(|item| { item.external_ref .as_deref() .map(|r| r.starts_with("flow-review-issue-")) .unwrap_or(false) }) .cloned() .collect(); let idx = todo::find_item_index(&review_items, id)?; let item = &review_items[idx]; let priority = item.priority.as_deref().unwrap_or("P4"); let indicator = match priority { "P1" => "P1 (critical)", "P2" => "P2 (high)", "P3" => "P3 (medium)", _ => "P4 (low)", }; println!("ID: {}", item.id); println!("Title: {}", item.title); println!("Priority: {}", indicator); println!("Status: {}", item.status); println!("Created: {}", item.created_at); if let Some(updated) = &item.updated_at { println!("Updated: {}", updated); } if let Some(note) = &item.note { println!("\n{}", note); } Ok(()) } fn done_review_todo(id: &str) -> Result<()> { let root = todo::project_root(); let (path, mut items) = todo::load_items_at_root(&root)?; // Find among review items only let review_indices: Vec<usize> = items .iter() .enumerate() .filter(|(_, item)| { item.external_ref .as_deref() .map(|r| r.starts_with("flow-review-issue-")) .unwrap_or(false) }) .map(|(i, _)| i) .collect(); // Match by id prefix among review items let mut matches = Vec::new(); for &idx in &review_indices { if items[idx].id == id || items[idx].id.starts_with(id) { matches.push(idx); } } let idx = match matches.len() { 0 => bail!("Review todo '{}' not found", id), 1 => matches[0], _ => bail!("Review todo id '{}' is ambiguous", id), }; if items[idx].status == "completed" { println!("Already completed: {}", items[idx].id); return Ok(()); } items[idx].status = "completed".to_string(); items[idx].updated_at = Some(Utc::now().to_rfc3339()); todo::save_items(&path, &items)?; println!("✓ {} -> completed", items[idx].id); Ok(()) } fn fix_review_todos(id: Option<&str>, all: bool) -> Result<()> { let root = todo::project_root(); let items = todo::load_review_todos(&root)?; let open_items: Vec<_> = items .iter() .filter(|item| item.status != "completed") .collect(); if open_items.is_empty() { println!("No open review todos to fix."); return Ok(()); } let to_fix: Vec<_> = if let Some(id) = id { let mut matched = Vec::new(); for item in &open_items { if item.id == id || item.id.starts_with(id) { matched.push(*item); } } if matched.is_empty() { bail!("Review todo '{}' not found among open items", id); } if matched.len() > 1 { bail!("Review todo id '{}' is ambiguous", id); } matched } else if all { open_items } else { bail!("Specify a todo id or use --all to fix all open review todos"); }; for item in &to_fix { fix_single_todo(&root, item)?; } Ok(()) } fn fix_single_todo(root: &Path, item: &todo::TodoItem) -> Result<()> { let short_id = &item.id[..item.id.len().min(8)]; let priority = item.priority.as_deref().unwrap_or("P4"); println!("==> Fixing [{}] {} : {}", priority, short_id, item.title); // Extract commit SHA from note (line starting with "Commit: ") let commit_sha = item .note .as_deref() .and_then(|note| { note.lines() .find(|line| line.starts_with("Commit: ")) .map(|line| line.trim_start_matches("Commit: ").trim().to_string()) }) .unwrap_or_default(); // Get the original diff if we have a commit SHA let diff = if !commit_sha.is_empty() { let output = Command::new("git") .args(["show", "--format=", "--patch", &commit_sha]) .current_dir(root) .output(); match output { Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), _ => String::new(), } } else { String::new() }; // Build the fix prompt let mut prompt = String::new(); prompt.push_str("Fix the following code review issue.\n\n"); prompt.push_str("Issue: "); prompt.push_str(&item.title); prompt.push('\n'); if let Some(note) = &item.note { prompt.push_str("\nDetails:\n"); prompt.push_str(note); prompt.push('\n'); } if !diff.is_empty() { prompt.push_str("\nOriginal diff:\n```\n"); // Truncate very large diffs let max_diff = 8000; if diff.len() > max_diff { prompt.push_str(&diff[..max_diff]); prompt.push_str("\n... (truncated)\n"); } else { prompt.push_str(&diff); } prompt.push_str("```\n"); } prompt .push_str("\nApply the minimal fix to resolve this issue. Only change what is necessary."); let codex_bin = commit::configured_codex_bin_for_workdir(root); // Run codex with the same configured binary resolution as commit reviews. let status = Command::new(&codex_bin) .args(["--approval-mode", "full-auto", "--quiet", &prompt]) .current_dir(root) .status(); match status { Ok(s) if s.success() => { println!(" ✓ Codex fix applied for {}", short_id); // Mark todo as completed let (path, mut all_items) = todo::load_items_at_root(root)?; if let Ok(idx) = todo::find_item_index(&all_items, &item.id) { all_items[idx].status = "completed".to_string(); all_items[idx].updated_at = Some(Utc::now().to_rfc3339()); todo::save_items(&path, &all_items)?; } Ok(()) } Ok(s) => { eprintln!( " ✗ Codex exited with status {} for {}", s.code().unwrap_or(-1), short_id ); Ok(()) } Err(e) => { eprintln!( " ✗ Failed to run codex (bin: {}) for {}: {}", codex_bin, short_id, e ); Ok(()) } } } ================================================ FILE: src/rl_signals.rs ================================================ use std::fs::{self, OpenOptions}; use std::io::{BufWriter, Write}; use std::path::PathBuf; use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; use std::thread; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{Map, Value, json}; use uuid::Uuid; use crate::config; use crate::secret_redact::redact_json_value; const DEFAULT_SIGNAL_PATH: &str = "out/logs/flow_rl_signals.jsonl"; const DEFAULT_QUEUE_CAPACITY: usize = 8192; struct SignalSink { enabled: bool, tx: Option<SyncSender<String>>, seq_mirror_enabled: bool, tx_seq: Option<SyncSender<String>>, dropped: AtomicU64, accepted: AtomicU64, dropped_seq: AtomicU64, accepted_seq: AtomicU64, } static SIGNAL_SINK: OnceLock<SignalSink> = OnceLock::new(); pub fn emit(mut payload: Value) { let sink = SIGNAL_SINK.get_or_init(SignalSink::from_env); if !sink.enabled { return; } if !payload.is_object() { payload = json!({ "payload": payload }); } if let Value::Object(obj) = &mut payload { obj.entry("schema_version".to_string()) .or_insert_with(|| Value::String("flow_rl_event_v1".to_string())); obj.entry("source".to_string()) .or_insert_with(|| Value::String("flow".to_string())); obj.entry("ts_unix_ms".to_string()) .or_insert_with(|| Value::Number(now_unix_ms().into())); } redact_json_value(&mut payload); let Ok(line) = serde_json::to_string(&payload) else { return; }; if let Some(tx) = sink.tx.as_ref() { if tx.try_send(line).is_ok() { sink.accepted.fetch_add(1, Ordering::Relaxed); } else { sink.dropped.fetch_add(1, Ordering::Relaxed); } } if sink.seq_mirror_enabled && let Some(seq_line) = payload_to_seq_router_row(&payload) && let Some(tx_seq) = sink.tx_seq.as_ref() { if tx_seq.try_send(seq_line).is_ok() { sink.accepted_seq.fetch_add(1, Ordering::Relaxed); } else { sink.dropped_seq.fetch_add(1, Ordering::Relaxed); } } } pub fn stats() -> Value { let sink = SIGNAL_SINK.get_or_init(SignalSink::from_env); json!({ "enabled": sink.enabled, "accepted": sink.accepted.load(Ordering::Relaxed), "dropped": sink.dropped.load(Ordering::Relaxed), "path": signal_path().display().to_string(), "seq_mirror": { "enabled": sink.seq_mirror_enabled, "accepted": sink.accepted_seq.load(Ordering::Relaxed), "dropped": sink.dropped_seq.load(Ordering::Relaxed), "path": seq_mirror_path().display().to_string(), } }) } impl SignalSink { fn from_env() -> Self { if !env_enabled() { return Self { enabled: false, tx: None, seq_mirror_enabled: false, tx_seq: None, dropped: AtomicU64::new(0), accepted: AtomicU64::new(0), dropped_seq: AtomicU64::new(0), accepted_seq: AtomicU64::new(0), }; } let path = signal_path(); if let Some(parent) = path.parent() { if fs::create_dir_all(parent).is_err() { return Self { enabled: false, tx: None, seq_mirror_enabled: false, tx_seq: None, dropped: AtomicU64::new(0), accepted: AtomicU64::new(0), dropped_seq: AtomicU64::new(0), accepted_seq: AtomicU64::new(0), }; } } let cap = std::env::var("FLOW_RL_SIGNALS_QUEUE") .ok() .and_then(|raw| raw.parse::<usize>().ok()) .unwrap_or(DEFAULT_QUEUE_CAPACITY) .max(64); let (tx, rx) = sync_channel::<String>(cap); let flush_every = flush_every(); thread::spawn(move || writer_loop(path, rx, flush_every)); let mut seq_mirror_enabled = false; let mut tx_seq = None; if seq_mirror_enabled_from_env() { let seq_path = seq_mirror_path(); if let Some(parent) = seq_path.parent() { if fs::create_dir_all(parent).is_ok() { let (seq_tx, seq_rx) = sync_channel::<String>(cap); thread::spawn(move || writer_loop(seq_path, seq_rx, flush_every)); tx_seq = Some(seq_tx); seq_mirror_enabled = true; } } } Self { enabled: true, tx: Some(tx), seq_mirror_enabled, tx_seq, dropped: AtomicU64::new(0), accepted: AtomicU64::new(0), dropped_seq: AtomicU64::new(0), accepted_seq: AtomicU64::new(0), } } } fn env_enabled() -> bool { let raw = std::env::var("FLOW_RL_SIGNALS").unwrap_or_else(|_| "true".to_string()); matches!( raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ) } fn signal_path() -> PathBuf { std::env::var("FLOW_RL_SIGNALS_PATH") .ok() .filter(|v| !v.trim().is_empty()) .map(|v| expand_tilde_path(&v)) .unwrap_or_else(|| PathBuf::from(DEFAULT_SIGNAL_PATH)) } fn seq_mirror_enabled_from_env() -> bool { let raw = std::env::var("FLOW_RL_SIGNALS_SEQ_MIRROR").unwrap_or_else(|_| "true".to_string()); matches!( raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ) } fn seq_mirror_path() -> PathBuf { std::env::var("FLOW_RL_SIGNALS_SEQ_PATH") .ok() .or_else(|| std::env::var("SEQ_CH_MEM_PATH").ok()) .filter(|v| !v.trim().is_empty()) .map(|v| expand_tilde_path(&v)) .unwrap_or_else(default_seq_mirror_path) } fn default_seq_mirror_path() -> PathBuf { config::global_state_dir().join("rl").join("seq_mem.jsonl") } fn expand_tilde_path(value: &str) -> PathBuf { if value == "~" && let Ok(home) = std::env::var("HOME") { return PathBuf::from(home); } if let Some(suffix) = value.strip_prefix("~/") && let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(suffix); } PathBuf::from(value) } fn writer_loop(path: PathBuf, rx: Receiver<String>, flush_every: usize) { let file = OpenOptions::new().create(true).append(true).open(&path); let Ok(file) = file else { return; }; let mut writer = BufWriter::new(file); let mut pending = 0usize; let flush_every = flush_every.max(1); for line in rx { if writer.write_all(line.as_bytes()).is_err() { continue; } if writer.write_all(b"\n").is_err() { continue; } pending += 1; if pending >= flush_every { let _ = writer.flush(); pending = 0; } } let _ = writer.flush(); } fn flush_every() -> usize { std::env::var("FLOW_RL_SIGNALS_FLUSH_EVERY") .ok() .and_then(|raw| raw.trim().parse::<usize>().ok()) .unwrap_or(1) .max(1) } fn now_unix_ms() -> u64 { match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(dur) => dur.as_millis() as u64, Err(_) => 0, } } fn payload_to_seq_router_row(payload: &Value) -> Option<String> { let obj = payload.as_object()?; let event_type = obj.get("event_type")?.as_str()?; if !event_type.starts_with("flow.router.") { return None; } let ts_ms = obj .get("ts_unix_ms") .and_then(Value::as_u64) .unwrap_or_else(now_unix_ms); let ok = obj.get("ok").and_then(Value::as_bool).unwrap_or(true); let session_id = obj .get("session_id") .and_then(Value::as_str) .unwrap_or("flow") .to_string(); let event_id = obj .get("event_id") .and_then(Value::as_str) .map(ToString::to_string) .unwrap_or_else(|| format!("evt_{}", Uuid::new_v4().simple())); let subject = obj .get("subject") .cloned() .unwrap_or_else(|| Value::Object(Map::new())); let subject_json = serde_json::to_string(&subject).ok()?; let row = json!({ "ts_ms": ts_ms, "dur_us": 0, "ok": ok, "session_id": session_id, "event_id": event_id, "content_hash": format!("flow-router-{}", Uuid::new_v4().simple()), "name": event_type, "subject": subject_json, }); serde_json::to_string(&row).ok() } pub fn attrs_to_object(attrs: Vec<(String, String)>) -> Map<String, Value> { let mut out = Map::new(); for (k, v) in attrs { if k.is_empty() { continue; } out.insert(k, Value::String(v)); } out } #[cfg(test)] mod tests { use super::*; #[test] fn router_event_maps_to_seq_row() { let payload = json!({ "event_type": "flow.router.decision.v1", "session_id": "sess-1", "ok": true, "ts_unix_ms": 1700000000000u64, "subject": { "decision_id": "dec-1", "chosen_task": "ai:flow/dev-check", } }); let line = payload_to_seq_router_row(&payload).expect("router event should map"); let parsed: Value = serde_json::from_str(&line).expect("json line"); assert_eq!( parsed.get("name").and_then(Value::as_str), Some("flow.router.decision.v1") ); assert_eq!( parsed.get("session_id").and_then(Value::as_str), Some("sess-1") ); assert_eq!(parsed.get("ok").and_then(Value::as_bool), Some(true)); assert_eq!( parsed.get("ts_ms").and_then(Value::as_u64), Some(1700000000000u64) ); assert!( parsed .get("subject") .and_then(Value::as_str) .unwrap_or("") .contains("\"decision_id\":\"dec-1\"") ); } #[test] fn non_router_event_not_mirrored() { let payload = json!({ "event_type": "everruns.run_started", "session_id": "sess-1", }); assert!(payload_to_seq_router_row(&payload).is_none()); } } ================================================ FILE: src/running.rs ================================================ use std::{ collections::HashMap, path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; /// A process started by flow #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunningProcess { /// Process ID of the main task process pub pid: u32, /// Process group ID (for killing child processes) pub pgid: u32, /// Name of the task from flow.toml pub task_name: String, /// Full command that was executed pub command: String, /// Timestamp when the process was started (ms since epoch) pub started_at: u128, /// Canonical path to the flow.toml that defines this task pub config_path: PathBuf, /// Canonical path to the project root directory pub project_root: PathBuf, /// Whether flox environment was used pub used_flox: bool, /// Optional project name from flow.toml #[serde(default)] pub project_name: Option<String>, } /// All running processes tracked by flow #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RunningProcesses { /// Map from project config path to list of running processes pub projects: HashMap<String, Vec<RunningProcess>>, } /// Returns ~/.config/flow/running.sqlite pub fn running_processes_path() -> PathBuf { crate::config::global_state_dir().join("running.sqlite") } fn open_running_db(path: &Path) -> Result<Connection> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let conn = Connection::open(path).with_context(|| format!("failed to open {}", path.display()))?; conn.busy_timeout(Duration::from_secs(5)) .context("failed to set running DB busy timeout")?; conn.pragma_update(None, "journal_mode", "WAL") .context("failed to enable running DB WAL")?; conn.pragma_update(None, "synchronous", "NORMAL") .context("failed to tune running DB sync mode")?; conn.pragma_update(None, "temp_store", "MEMORY") .context("failed to tune running DB temp store")?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS running_processes ( pid INTEGER PRIMARY KEY, pgid INTEGER NOT NULL, task_name TEXT NOT NULL, command TEXT NOT NULL, started_at INTEGER NOT NULL, config_path TEXT NOT NULL, project_root TEXT NOT NULL, used_flox INTEGER NOT NULL, project_name TEXT ); CREATE INDEX IF NOT EXISTS idx_running_processes_config_path ON running_processes(config_path); CREATE INDEX IF NOT EXISTS idx_running_processes_started_at ON running_processes(started_at);", ) .context("failed to initialize running-process schema")?; Ok(conn) } fn read_processes(conn: &Connection, config_path: Option<&Path>) -> Result<Vec<RunningProcess>> { let mut processes = Vec::new(); if let Some(config_path) = config_path { let config_path = config_path.to_string_lossy().to_string(); let mut stmt = conn .prepare( "SELECT pid, pgid, task_name, command, started_at, config_path, project_root, used_flox, project_name FROM running_processes WHERE config_path = ?1 ORDER BY started_at ASC", ) .context("failed to prepare filtered running-process query")?; let rows = stmt .query_map(params![config_path], row_to_running_process) .context("failed to query filtered running processes")?; for row in rows { processes.push(row.context("failed to decode running process row")?); } } else { let mut stmt = conn .prepare( "SELECT pid, pgid, task_name, command, started_at, config_path, project_root, used_flox, project_name FROM running_processes ORDER BY started_at ASC", ) .context("failed to prepare running-process query")?; let rows = stmt .query_map([], row_to_running_process) .context("failed to query running processes")?; for row in rows { processes.push(row.context("failed to decode running process row")?); } } Ok(processes) } fn row_to_running_process(row: &rusqlite::Row<'_>) -> rusqlite::Result<RunningProcess> { let started_at_raw: i64 = row.get(4)?; Ok(RunningProcess { pid: row.get(0)?, pgid: row.get(1)?, task_name: row.get(2)?, command: row.get(3)?, started_at: u128::try_from(started_at_raw.max(0)).unwrap_or(0), config_path: PathBuf::from(row.get::<_, String>(5)?), project_root: PathBuf::from(row.get::<_, String>(6)?), used_flox: row.get::<_, i64>(7)? != 0, project_name: row.get(8)?, }) } fn remove_processes(conn: &mut Connection, pids: &[u32]) -> Result<()> { if pids.is_empty() { return Ok(()); } let tx = conn .transaction() .context("failed to start running-process cleanup transaction")?; { let mut stmt = tx .prepare("DELETE FROM running_processes WHERE pid = ?1") .context("failed to prepare running-process cleanup statement")?; for pid in pids { stmt.execute(params![pid]) .with_context(|| format!("failed to delete stale running process {}", pid))?; } } tx.commit() .context("failed to commit running-process cleanup transaction")?; Ok(()) } fn collect_alive_processes( conn: &mut Connection, config_path: Option<&Path>, ) -> Result<Vec<RunningProcess>> { let rows = read_processes(conn, config_path)?; let mut alive = Vec::with_capacity(rows.len()); let mut stale = Vec::new(); for process in rows { if process_alive(process.pid) { alive.push(process); } else { stale.push(process.pid); } } remove_processes(conn, &stale)?; Ok(alive) } fn load_running_processes_at(path: &Path) -> Result<RunningProcesses> { let mut conn = open_running_db(path)?; let processes = collect_alive_processes(&mut conn, None)?; let mut grouped: HashMap<String, Vec<RunningProcess>> = HashMap::new(); for process in processes { grouped .entry(process.config_path.display().to_string()) .or_default() .push(process); } Ok(RunningProcesses { projects: grouped }) } fn register_process_at(path: &Path, entry: RunningProcess) -> Result<()> { let mut conn = open_running_db(path)?; let tx = conn .transaction() .context("failed to start running-process register transaction")?; tx.execute( "INSERT INTO running_processes ( pid, pgid, task_name, command, started_at, config_path, project_root, used_flox, project_name ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(pid) DO UPDATE SET pgid = excluded.pgid, task_name = excluded.task_name, command = excluded.command, started_at = excluded.started_at, config_path = excluded.config_path, project_root = excluded.project_root, used_flox = excluded.used_flox, project_name = excluded.project_name", params![ entry.pid, entry.pgid, entry.task_name, entry.command, i64::try_from(entry.started_at).unwrap_or(i64::MAX), entry.config_path.display().to_string(), entry.project_root.display().to_string(), if entry.used_flox { 1i64 } else { 0i64 }, entry.project_name, ], ) .with_context(|| format!("failed to register running process {}", entry.task_name))?; tx.commit() .context("failed to commit running-process register transaction")?; Ok(()) } fn unregister_process_at(path: &Path, pid: u32) -> Result<()> { let conn = open_running_db(path)?; conn.execute("DELETE FROM running_processes WHERE pid = ?1", params![pid]) .with_context(|| format!("failed to unregister running process {}", pid))?; Ok(()) } fn get_project_processes_at(path: &Path, config_path: &Path) -> Result<Vec<RunningProcess>> { let mut conn = open_running_db(path)?; collect_alive_processes(&mut conn, Some(config_path)) } /// Load running processes, validating that PIDs are still alive. pub fn load_running_processes() -> Result<RunningProcesses> { load_running_processes_at(&running_processes_path()) } /// Register a new running process. pub fn register_process(entry: RunningProcess) -> Result<()> { register_process_at(&running_processes_path(), entry) } /// Unregister a process by PID. pub fn unregister_process(pid: u32) -> Result<()> { unregister_process_at(&running_processes_path(), pid) } /// Get processes for a specific project. pub fn get_project_processes(config_path: &Path) -> Result<Vec<RunningProcess>> { get_project_processes_at(&running_processes_path(), config_path) } /// Check if a process is alive. pub fn process_alive(pid: u32) -> bool { #[cfg(unix)] { let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; if result == 0 { return true; } matches!( std::io::Error::last_os_error().raw_os_error(), Some(libc::EPERM) ) } #[cfg(windows)] { use std::process::Command; use std::process::Stdio; Command::new("tasklist") .stdout(Stdio::piped()) .stderr(Stdio::null()) .output() .map(|o| { o.status.success() && String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()) }) .unwrap_or(false) } } /// Get process group ID for a PID. #[cfg(unix)] pub fn get_pgid(pid: u32) -> Option<u32> { let pgid = unsafe { libc::getpgid(pid as libc::pid_t) }; if pgid < 0 { None } else { Some(pgid as u32) } } #[cfg(not(unix))] pub fn get_pgid(pid: u32) -> Option<u32> { Some(pid) } /// Get current timestamp in milliseconds. pub fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn sample_process(pid: u32, root: &Path) -> RunningProcess { RunningProcess { pid, pgid: get_pgid(std::process::id()).unwrap_or(pid), task_name: "dev".to_string(), command: "cargo run".to_string(), started_at: now_ms(), config_path: root.join("flow.toml"), project_root: root.to_path_buf(), used_flox: false, project_name: Some("flow".to_string()), } } #[test] fn register_load_and_unregister_round_trip() { let dir = TempDir::new().expect("tempdir"); let db_path = dir.path().join("running.sqlite"); let process = sample_process(std::process::id(), dir.path()); register_process_at(&db_path, process.clone()).expect("register process"); let loaded = load_running_processes_at(&db_path).expect("load processes"); let key = process.config_path.display().to_string(); let entries = loaded.projects.get(&key).expect("project entries"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].pid, process.pid); let project_entries = get_project_processes_at(&db_path, &process.config_path).expect("project entries"); assert_eq!(project_entries.len(), 1); assert_eq!(project_entries[0].task_name, process.task_name); unregister_process_at(&db_path, process.pid).expect("unregister process"); let loaded = load_running_processes_at(&db_path).expect("reload processes"); assert!(loaded.projects.is_empty()); } #[test] fn stale_processes_are_removed_on_read() { let dir = TempDir::new().expect("tempdir"); let db_path = dir.path().join("running.sqlite"); let process = sample_process(999_999, dir.path()); register_process_at(&db_path, process.clone()).expect("register process"); let loaded = load_running_processes_at(&db_path).expect("load processes"); assert!( loaded.projects.is_empty(), "stale process should be dropped" ); let project_entries = get_project_processes_at(&db_path, &process.config_path).expect("project entries"); assert!( project_entries.is_empty(), "stale project process should be removed" ); } } ================================================ FILE: src/screen.rs ================================================ use anyhow::{Context, Result}; use serde::Serialize; use std::{ fmt, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::{ sync::{RwLock, broadcast}, time, }; use crate::cli::ScreenOpts; #[derive(Clone)] pub struct ScreenBroadcaster { sender: broadcast::Sender<ScreenFrame>, latest: Arc<RwLock<Option<ScreenFrame>>>, } #[derive(Debug, Clone, Serialize)] pub struct ScreenFrame { pub frame_number: u64, pub captured_at_ms: u128, pub encoding: String, pub payload: String, } impl fmt::Display for ScreenFrame { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "#{frame:<5} @ {ts}ms | {}", self.payload, frame = self.frame_number, ts = self.captured_at_ms ) } } impl ScreenBroadcaster { pub fn with_mock_stream(buffer: usize, fps: u8) -> Self { let broadcaster = Self::new(buffer); broadcaster.spawn_mock_stream(fps); broadcaster } pub fn new(buffer: usize) -> Self { let (sender, _) = broadcast::channel(buffer); Self { sender, latest: Arc::new(RwLock::new(None)), } } pub fn subscribe(&self) -> broadcast::Receiver<ScreenFrame> { self.sender.subscribe() } pub async fn latest(&self) -> Option<ScreenFrame> { self.latest.read().await.clone() } fn spawn_mock_stream(&self, fps: u8) { let fps = fps.max(1); let period = Duration::from_millis((1000 / fps as u64).max(1)); let mut frame_number = 0_u64; let handle = self.clone(); tokio::spawn(async move { let mut ticker = time::interval(period); loop { ticker.tick().await; frame_number += 1; let payload = build_ascii_frame(frame_number); let frame = ScreenFrame { frame_number, captured_at_ms: current_epoch_ms(), encoding: "text/mock".to_string(), payload, }; handle.publish(frame).await; } }); } async fn publish(&self, frame: ScreenFrame) { { let mut guard = self.latest.write().await; *guard = Some(frame.clone()); } // Ignore lagging consumers; they'll resubscribe and catch up. let _ = self.sender.send(frame); } } pub async fn preview(opts: ScreenOpts) -> Result<()> { let generator = ScreenBroadcaster::with_mock_stream(opts.frame_buffer, opts.fps); let mut rx = generator.subscribe(); for _ in 0..opts.frames { let frame = rx .recv() .await .context("screen preview channel closed unexpectedly")?; println!("{frame}"); } Ok(()) } fn current_epoch_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|dur| dur.as_millis()) .unwrap_or(0) } fn build_ascii_frame(frame: u64) -> String { const WIDTH: usize = 32; const FILL: char = '#'; const EMPTY: char = '.'; let position = (frame as usize) % WIDTH; let mut line = String::with_capacity(WIDTH); for idx in 0..WIDTH { if idx == position { line.push(FILL); } else { line.push(EMPTY); } } line } ================================================ FILE: src/sealer_crypto.rs ================================================ use anyhow::Result; use bs58; use crypto_secretbox::{ XSalsa20Poly1305, aead::{Aead, KeyInit}, }; use rand::{TryRng, rngs::SysRng}; use x25519_dalek::{PublicKey, StaticSecret}; const SECRET_PREFIX: &str = "sealerSecret_z"; const ID_PREFIX: &str = "sealer_z"; pub fn new_x25519_private_key() -> Vec<u8> { let mut bytes = [0u8; 32]; SysRng .try_fill_bytes(&mut bytes) .expect("system RNG should provide x25519 key material"); bytes.to_vec() } pub fn get_sealer_id(secret: &str) -> Result<String> { let secret_raw = secret .strip_prefix(SECRET_PREFIX) .ok_or_else(|| anyhow::anyhow!("invalid sealer secret prefix"))?; let private_bytes = bs58::decode(secret_raw) .into_vec() .map_err(|e| anyhow::anyhow!("invalid base58 sealer secret: {e}"))?; let bytes: [u8; 32] = private_bytes .as_slice() .try_into() .map_err(|_| anyhow::anyhow!("invalid sealer secret length"))?; let public = PublicKey::from(&StaticSecret::from(bytes)).to_bytes(); Ok(format!( "{}{}", ID_PREFIX, bs58::encode(public).into_string() )) } pub fn seal( message: &[u8], sender_secret: &str, recipient_id: &str, nonce_material: &[u8], ) -> Result<Vec<u8>> { let sender_secret = decode_secret(sender_secret)?; let recipient_public = decode_id(recipient_id)?; let sender_key = StaticSecret::from(sender_secret); let recipient_key = PublicKey::from(recipient_public); let shared_secret = sender_key.diffie_hellman(&recipient_key).to_bytes(); let nonce = derive_nonce(nonce_material); let cipher = XSalsa20Poly1305::new(&shared_secret.into()); let ciphertext = cipher .encrypt(&nonce.into(), message) .map_err(|_| anyhow::anyhow!("failed to seal message"))?; Ok(ciphertext) } pub fn unseal( sealed_message: &[u8], recipient_secret: &str, sender_id: &str, nonce_material: &[u8], ) -> Result<Vec<u8>> { let recipient_secret = decode_secret(recipient_secret)?; let sender_public = decode_id(sender_id)?; let recipient_key = StaticSecret::from(recipient_secret); let sender_key = PublicKey::from(sender_public); let shared_secret = recipient_key.diffie_hellman(&sender_key).to_bytes(); let nonce = derive_nonce(nonce_material); let cipher = XSalsa20Poly1305::new(&shared_secret.into()); let plaintext = cipher .decrypt(&nonce.into(), sealed_message) .map_err(|_| anyhow::anyhow!("failed to unseal message"))?; Ok(plaintext) } fn decode_secret(value: &str) -> Result<[u8; 32]> { let encoded = value .strip_prefix(SECRET_PREFIX) .ok_or_else(|| anyhow::anyhow!("invalid sealer secret prefix"))?; let bytes = bs58::decode(encoded) .into_vec() .map_err(|e| anyhow::anyhow!("invalid base58 secret: {e}"))?; bytes .as_slice() .try_into() .map_err(|_| anyhow::anyhow!("invalid secret key length")) } fn decode_id(value: &str) -> Result<[u8; 32]> { let encoded = value .strip_prefix(ID_PREFIX) .ok_or_else(|| anyhow::anyhow!("invalid sealer id prefix"))?; let bytes = bs58::decode(encoded) .into_vec() .map_err(|e| anyhow::anyhow!("invalid base58 id: {e}"))?; bytes .as_slice() .try_into() .map_err(|_| anyhow::anyhow!("invalid public key length")) } fn derive_nonce(nonce_material: &[u8]) -> [u8; 24] { let hash = blake3::hash(nonce_material); let mut nonce = [0u8; 24]; nonce.copy_from_slice(&hash.as_bytes()[..24]); nonce } ================================================ FILE: src/secret_redact.rs ================================================ use std::collections::HashSet; use std::sync::OnceLock; use regex::{Captures, Regex}; use serde_json::Value; const REDACTED: &str = "[REDACTED]"; pub fn redact_text(input: &str) -> String { if input.is_empty() { return String::new(); } let mut text = input.to_string(); text = url_credentials_regex() .replace_all(&text, |caps: &Captures| { let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or_default(); format!("{prefix}{REDACTED}@") }) .to_string(); text = bearer_regex() .replace_all(&text, |caps: &Captures| { let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or_default(); format!("{prefix}{REDACTED}") }) .to_string(); text = quoted_assignment_regex() .replace_all(&text, |caps: &Captures| { let full = caps.get(0).map(|m| m.as_str()).unwrap_or_default(); let key = caps.name("key").map(|m| m.as_str()).unwrap_or_default(); if !is_sensitive_key(key) { return full.to_string(); } let value = caps.name("value").map(|m| m.as_str()).unwrap_or_default(); if should_keep_assignment_value(value) { return full.to_string(); } let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or_default(); let suffix = caps.name("suffix").map(|m| m.as_str()).unwrap_or_default(); format!("{prefix}{REDACTED}{suffix}") }) .to_string(); text = unquoted_assignment_regex() .replace_all(&text, |caps: &Captures| { let full = caps.get(0).map(|m| m.as_str()).unwrap_or_default(); let key = caps.name("key").map(|m| m.as_str()).unwrap_or_default(); if !is_sensitive_key(key) { return full.to_string(); } let value = caps.name("value").map(|m| m.as_str()).unwrap_or_default(); if should_keep_assignment_value(value) { return full.to_string(); } let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or_default(); format!("{prefix}{REDACTED}") }) .to_string(); text = known_token_regex().replace_all(&text, REDACTED).to_string(); text = generic_token_regex() .replace_all(&text, |caps: &Captures| { let token = caps.name("token").map(|m| m.as_str()).unwrap_or_default(); if looks_like_secretish_token(token) { REDACTED.to_string() } else { token.to_string() } }) .to_string(); text } pub fn redact_json_value(value: &mut Value) { match value { Value::String(s) => { *s = redact_text(s); } Value::Array(items) => { for item in items { redact_json_value(item); } } Value::Object(map) => { for (key, item) in map.iter_mut() { if is_sensitive_key(key) { if let Value::String(_) = item { *item = Value::String(REDACTED.to_string()); continue; } } redact_json_value(item); } } _ => {} } } fn should_keep_assignment_value(value: &str) -> bool { let trimmed = value.trim().trim_matches('"').trim_matches('\''); if trimmed.is_empty() { return true; } if trimmed == REDACTED { return true; } let lower = trimmed.to_ascii_lowercase(); if matches!( lower.as_str(), "true" | "false" | "null" | "none" | "undefined" ) { return true; } trimmed.starts_with('$') || trimmed.starts_with("${") || trimmed.starts_with("$(") } fn looks_like_secretish_token(token: &str) -> bool { if token.len() < 28 || token.len() > 256 { return false; } if token.chars().all(|c| c.is_ascii_hexdigit()) { return false; } let has_alpha = token.chars().any(|c| c.is_ascii_alphabetic()); let has_digit = token.chars().any(|c| c.is_ascii_digit()); if !has_alpha || !has_digit { return false; } let has_upper = token.chars().any(|c| c.is_ascii_uppercase()); let has_symbol = token.contains('-') || token.contains('_'); if !has_upper && !has_symbol { return false; } let mut unique = HashSet::new(); for ch in token.chars() { unique.insert(ch); } if unique.len() < 8 { return false; } shannon_entropy(token) >= 3.6 } fn is_sensitive_key(raw_key: &str) -> bool { if raw_key.is_empty() { return false; } let key = raw_key.to_ascii_lowercase(); if key == "authorization" || key == "x-api-key" { return true; } let needles = [ "token", "secret", "password", "passwd", "pwd", "api_key", "apikey", "private_key", "private-key", "client_secret", "client-secret", "bearer", ]; needles.iter().any(|needle| key.contains(needle)) } fn shannon_entropy(input: &str) -> f64 { if input.is_empty() { return 0.0; } let mut counts = [0usize; 256]; for byte in input.bytes() { counts[usize::from(byte)] += 1; } let len = input.len() as f64; let mut entropy = 0.0f64; for count in counts { if count == 0 { continue; } let p = count as f64 / len; entropy -= p * p.log2(); } entropy } fn url_credentials_regex() -> &'static Regex { static RE: OnceLock<Regex> = OnceLock::new(); RE.get_or_init(|| { Regex::new(r"(?i)(?P<prefix>https?://)(?P<creds>[^\s/@:]+:[^\s/@]+)@") .expect("valid url credentials regex") }) } fn bearer_regex() -> &'static Regex { static RE: OnceLock<Regex> = OnceLock::new(); RE.get_or_init(|| { Regex::new(r"(?i)(?P<prefix>\bbearer\s+)(?P<token>[A-Za-z0-9._~+/=-]{12,})") .expect("valid bearer regex") }) } fn quoted_assignment_regex() -> &'static Regex { static RE: OnceLock<Regex> = OnceLock::new(); RE.get_or_init(|| { Regex::new( r#"(?i)(?P<prefix>["']?(?P<key>[A-Za-z_][A-Za-z0-9_-]{0,127})["']?\s*[:=]\s*["'])(?P<value>[^"'\\n]{4,})(?P<suffix>["'])"#, ) .expect("valid quoted assignment regex") }) } fn unquoted_assignment_regex() -> &'static Regex { static RE: OnceLock<Regex> = OnceLock::new(); RE.get_or_init(|| { Regex::new( r#"(?i)(?P<prefix>["']?(?P<key>[A-Za-z_][A-Za-z0-9_-]{0,127})["']?\s*[:=]\s*)(?P<value>(?:bearer\s+)?[^\s,;'"\}\]]+)"#, ) .expect("valid unquoted assignment regex") }) } fn known_token_regex() -> &'static Regex { static RE: OnceLock<Regex> = OnceLock::new(); RE.get_or_init(|| { Regex::new( r"(?i)\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|CFPAT-[A-Za-z0-9_-]{20,})\b", ) .expect("valid known token regex") }) } fn generic_token_regex() -> &'static Regex { static RE: OnceLock<Regex> = OnceLock::new(); RE.get_or_init(|| { Regex::new(r"\b(?P<token>[A-Za-z0-9][A-Za-z0-9_-]{27,})\b") .expect("valid generic token regex") }) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn test_token() -> String { ["abcDEF0123", "456789TOKEN"].concat() } #[test] fn redacts_bearer_and_assignments() { let token = test_token(); let input = format!("Authorization: Bearer {token}\nCLOUDFLARE_API_TOKEN={token}-foo"); let redacted = redact_text(&input); assert!(redacted.contains("[REDACTED]")); assert!(redacted.contains("CLOUDFLARE_API_TOKEN=")); assert!(!redacted.contains(&token)); } #[test] fn redacts_url_credentials() { let input = "https://user:supersecret@example.com/path"; let redacted = redact_text(input); assert_eq!(redacted, "https://[REDACTED]@example.com/path"); } #[test] fn redacts_json_values_recursively() { let token = test_token(); let mut value = json!({ "headers": {"Authorization": format!("Bearer {token}")}, "nested": [{"token": format!("{token}-foo")}] }); redact_json_value(&mut value); let text = value.to_string(); assert!(text.contains("[REDACTED]")); assert!(!text.contains(&token)); } } ================================================ FILE: src/secrets.rs ================================================ use std::{ collections::HashMap, env, fs, path::{Path, PathBuf}, }; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use crate::{ cli::{SecretsAction, SecretsCommand, SecretsFormat, SecretsListOpts, SecretsPullOpts}, config::{self, Config, StorageConfig, StorageEnvConfig}, }; pub fn run(cmd: SecretsCommand) -> Result<()> { match cmd.action { SecretsAction::List(opts) => list(opts), SecretsAction::Pull(opts) => pull(opts), } } fn list(opts: SecretsListOpts) -> Result<()> { let (config_path, cfg) = load_config(opts.config)?; let secrets = cfg.storage.ok_or_else(|| { anyhow::anyhow!("no [storage] block defined in {}", config_path.display()) })?; if secrets.envs.is_empty() { println!( "No secret environments defined in {}", config_path.display() ); return Ok(()); } println!( "Environments defined in {} (provider: {}):", config_path.display(), secrets.provider ); for env_cfg in &secrets.envs { println!("\n- {}", env_cfg.name); if let Some(desc) = &env_cfg.description { println!(" Description: {}", desc); } if env_cfg.variables.is_empty() { println!(" Variables: (unspecified)"); } else { let summary: Vec<String> = env_cfg .variables .iter() .map(|var| match &var.default { Some(default) if !default.is_empty() => { format!("{} (default: {})", var.key, default) } Some(_) => format!("{} (default: empty)", var.key), None => var.key.clone(), }) .collect(); println!(" Variables: {}", summary.join(", ")); } } Ok(()) } fn pull(opts: SecretsPullOpts) -> Result<()> { let (config_path, cfg) = load_config(opts.config)?; let secrets = cfg.storage.ok_or_else(|| { anyhow::anyhow!("no [storage] block defined in {}", config_path.display()) })?; let env_cfg = secrets .envs .iter() .find(|env| env.name == opts.env) .ok_or_else(|| { anyhow::anyhow!( "unknown storage environment '{}' (available: {})", opts.env, secrets .envs .iter() .map(|env| env.name.as_str()) .collect::<Vec<_>>() .join(", ") ) })?; let values = fetch_remote_secrets(&secrets, env_cfg, opts.hub.clone())?; let ordered = order_variables(env_cfg, &values); let rendered = render_secrets(&ordered, opts.format); if let Some(path) = opts.output { write_output(&path, &rendered)?; println!("Saved {} secrets to {}", env_cfg.name, path.display()); } else { println!("{}", rendered); } Ok(()) } fn fetch_remote_secrets( cfg: &StorageConfig, env_cfg: &StorageEnvConfig, hub_override: Option<String>, ) -> Result<HashMap<String, String>> { let api_key = env::var(&cfg.env_var).with_context(|| { format!( "environment variable {} is not set; required to authenticate with secrets provider", cfg.env_var ) })?; let base_url = hub_override .or_else(|| Some(cfg.hub_url.clone())) .unwrap_or_else(|| "https://myflow.sh".to_string()); let base = base_url.trim_end_matches('/'); let url = format!("{}/api/secrets/{}/{}", base, cfg.provider, env_cfg.name); let client = Client::builder() .build() .context("failed to build HTTP client")?; let response = client .get(url) .bearer_auth(api_key) .send() .with_context(|| "failed to call storage hub")? .error_for_status() .with_context(|| "storage hub returned an error response")?; let mut body: HashMap<String, String> = response .json() .with_context(|| "failed to parse storage hub response")?; for var in &env_cfg.variables { if body.contains_key(&var.key) { continue; } if let Some(default) = &var.default { body.insert(var.key.clone(), default.clone()); } else { bail!( "storage hub response missing required variable '{}' for environment '{}'", var.key, env_cfg.name ); } } Ok(body) } fn order_variables( env_cfg: &StorageEnvConfig, values: &HashMap<String, String>, ) -> Vec<(String, String)> { let mut ordered = Vec::new(); for var in &env_cfg.variables { if let Some(value) = values.get(&var.key) { ordered.push((var.key.clone(), value.clone())); } } for (key, value) in values { if env_cfg.variables.iter().any(|v| v.key == *key) { continue; } ordered.push((key.clone(), value.clone())); } ordered } fn render_secrets(vars: &[(String, String)], format: SecretsFormat) -> String { match format { SecretsFormat::Shell => vars .iter() .map(|(k, v)| format!("export {}={}", k, shell_quote(v))) .collect::<Vec<_>>() .join("\n"), SecretsFormat::Dotenv => vars .iter() .map(|(k, v)| format!("{}={}", k, dotenv_quote(v))) .collect::<Vec<_>>() .join("\n"), } } fn shell_quote(value: &str) -> String { if value.is_empty() { "''".to_string() } else if value .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/')) { value.to_string() } else { let escaped = value.replace('\'', "'\\''"); format!("'{}'", escaped) } } fn dotenv_quote(value: &str) -> String { if value .bytes() .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'.' | b'-' | b'/')) { value.to_string() } else { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } } fn write_output(path: &Path, contents: &str) -> Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } } fs::write(path, contents.as_bytes()) .with_context(|| format!("failed to write secrets to {}", path.display()))?; Ok(()) } fn load_config(path: PathBuf) -> Result<(PathBuf, Config)> { let config_path = resolve_path(path)?; let cfg = config::load(&config_path).with_context(|| { format!( "failed to load configuration from {}", config_path.display() ) })?; Ok((config_path, cfg)) } fn resolve_path(path: PathBuf) -> Result<PathBuf> { if path.is_absolute() { Ok(path) } else { Ok(env::current_dir()?.join(path)) } } #[cfg(test)] mod tests { use super::*; use mockito::Server; use std::path::PathBuf; fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative) } struct EnvVarGuard { key: String, previous: Option<String>, } impl EnvVarGuard { fn set(key: &str, value: &str) -> Self { let previous = env::var(key).ok(); unsafe { env::set_var(key, value); } Self { key: key.to_string(), previous, } } } impl Drop for EnvVarGuard { fn drop(&mut self) { if let Some(value) = &self.previous { unsafe { env::set_var(&self.key, value); } } else { unsafe { env::remove_var(&self.key); } } } } #[test] fn project_config_fixture_is_loadable_and_fetches_mocked_secrets() { let cfg = config::load(fixture_path("test-data/project-config/flow.toml")) .expect("project config fixture should parse"); assert_eq!(cfg.tasks.len(), 3, "fixture defines three tasks"); let commit = cfg .tasks .iter() .find(|task| task.name == "commit") .expect("commit task should exist"); assert_eq!( commit.dependencies, ["github.com/nikivdev/fast"], "commit task should depend on fast" ); let storage = cfg .storage .clone() .expect("fixture should define a storage provider"); assert_eq!(storage.provider, "myflow.sh"); let env_cfg = storage .envs .iter() .find(|env| env.name == "local") .expect("local storage env should exist"); let _guard = EnvVarGuard::set(&storage.env_var, "test-token"); let mut server = Server::new(); let endpoint = format!("/api/secrets/{}/{}", storage.provider, env_cfg.name); let mock = server .mock("GET", endpoint.as_str()) .match_header("authorization", "Bearer test-token") .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{ "DATABASE_URL": "postgres://localhost/flow" }"#, ) .create(); let values = fetch_remote_secrets(&storage, env_cfg, Some(server.url())).expect("mock fetch works"); mock.assert(); assert_eq!( values.get("DATABASE_URL").map(String::as_str), Some("postgres://localhost/flow") ); assert_eq!(values.get("OPENAI_API_KEY").map(String::as_str), Some("")); } } ================================================ FILE: src/seq_client.rs ================================================ use std::io::{BufRead, BufReader, Write}; use std::path::Path; use std::time::Duration; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RpcRequest { pub op: String, #[serde(skip_serializing_if = "Option::is_none")] pub args: Option<Value>, #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub run_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option<String>, } impl RpcRequest { pub fn new(op: impl Into<String>) -> Self { Self { op: op.into(), args: None, request_id: None, run_id: None, tool_call_id: None, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RpcResponse { pub ok: bool, pub op: String, #[serde(default)] pub request_id: String, #[serde(default)] pub run_id: String, #[serde(default)] pub tool_call_id: String, #[serde(default)] pub ts_ms: u64, #[serde(default)] pub dur_us: u64, #[serde(default)] pub result: Option<Value>, #[serde(default)] pub error: Option<String>, } #[cfg(unix)] pub struct SeqClient { reader: BufReader<std::os::unix::net::UnixStream>, read_buf: Vec<u8>, } #[cfg(unix)] impl SeqClient { pub fn connect_with_timeout(socket_path: impl AsRef<Path>, timeout: Duration) -> Result<Self> { let path = socket_path.as_ref(); let stream = std::os::unix::net::UnixStream::connect(path) .with_context(|| format!("failed to connect to seqd socket {}", path.display()))?; stream .set_read_timeout(Some(timeout)) .context("failed to set seqd socket read timeout")?; stream .set_write_timeout(Some(timeout)) .context("failed to set seqd socket write timeout")?; Ok(Self { reader: BufReader::new(stream), read_buf: Vec::with_capacity(1024), }) } pub fn call(&mut self, req: &RpcRequest) -> Result<RpcResponse> { let mut encoded = serde_json::to_vec(req).context("failed to encode seqd rpc request")?; encoded.push(b'\n'); let stream = self.reader.get_mut(); stream .write_all(&encoded) .context("failed to write seqd rpc request")?; stream.flush().context("failed to flush seqd rpc request")?; self.read_buf.clear(); self.reader .read_until(b'\n', &mut self.read_buf) .context("failed to read seqd rpc response")?; if self.read_buf.last() == Some(&b'\n') { self.read_buf.pop(); } if self.read_buf.is_empty() { bail!("empty response from seqd"); } if self.read_buf.len() > 1_000_000 { bail!("seqd rpc response exceeded 1MB line limit"); } let resp: RpcResponse = crate::json_parse::parse_json_bytes_in_place(&mut self.read_buf) .context("failed to decode seqd rpc response json")?; Ok(resp) } } #[cfg(not(unix))] pub struct SeqClient; #[cfg(not(unix))] impl SeqClient { pub fn connect_with_timeout( _socket_path: impl AsRef<Path>, _timeout: Duration, ) -> Result<Self> { bail!("seq client is only supported on unix platforms") } pub fn call(&mut self, _req: &RpcRequest) -> Result<RpcResponse> { bail!("seq client is only supported on unix platforms") } } #[cfg(test)] #[cfg(unix)] mod tests { use super::*; use std::io::Read; use std::os::unix::net::UnixListener; use std::thread; use tempfile::tempdir; #[test] fn rpc_roundtrip_line_delimited() -> Result<()> { let dir = tempdir()?; let socket_path = dir.path().join("seqd.sock"); let listener = UnixListener::bind(&socket_path)?; let server = thread::spawn(move || -> Result<()> { let (mut stream, _) = listener.accept()?; let mut got = Vec::new(); let mut byte = [0u8; 1]; loop { let n = stream.read(&mut byte)?; if n == 0 || byte[0] == b'\n' { break; } got.push(byte[0]); } let req_text = String::from_utf8(got).context("req not utf8")?; let req: RpcRequest = serde_json::from_str(&req_text).context("req not json")?; if req.op != "ping" { bail!("unexpected op"); } let reply = serde_json::json!({ "ok": true, "op": "ping", "request_id": req.request_id.unwrap_or_default(), "run_id": req.run_id.unwrap_or_default(), "tool_call_id": req.tool_call_id.unwrap_or_default(), "ts_ms": 1, "dur_us": 2, "result": {"pong": true} }) .to_string(); stream.write_all(reply.as_bytes())?; stream.write_all(b"\n")?; Ok(()) }); let mut client = SeqClient::connect_with_timeout(&socket_path, Duration::from_secs(2))?; let mut req = RpcRequest::new("ping"); req.request_id = Some("abc".to_string()); let resp = client.call(&req)?; assert!(resp.ok); assert_eq!(resp.op, "ping"); assert_eq!(resp.request_id, "abc"); server.join().expect("server thread panicked")?; Ok(()) } } ================================================ FILE: src/seq_rpc.rs ================================================ use std::path::PathBuf; use std::time::Duration; use anyhow::{Context, Result, bail}; use serde_json::{Value, json}; use crate::cli::{ SeqRpcAction, SeqRpcCommand, SeqRpcIdOpts, SeqRpcOpenAppOpts, SeqRpcRawOpts, SeqRpcScreenshotOpts, }; use crate::seq_client::{RpcRequest, SeqClient}; pub fn run(cmd: SeqRpcCommand) -> Result<()> { let socket = resolve_socket_path(cmd.socket); let timeout = Duration::from_millis(cmd.timeout_ms.max(1)); let mut client = SeqClient::connect_with_timeout(&socket, timeout) .with_context(|| format!("failed to connect to seqd at {}", socket.display()))?; let req = build_request(cmd.action)?; let resp = client.call(&req)?; if cmd.pretty { println!("{}", serde_json::to_string_pretty(&resp)?); } else { println!("{}", serde_json::to_string(&resp)?); } if resp.ok { Ok(()) } else { bail!( "seqd rpc op '{}' failed: {}", resp.op, resp.error.unwrap_or_else(|| "unknown error".to_string()) ) } } fn resolve_socket_path(cli_socket: Option<PathBuf>) -> PathBuf { if let Some(path) = cli_socket { return path; } if let Ok(value) = std::env::var("SEQ_SOCKET_PATH") && !value.trim().is_empty() { return PathBuf::from(value); } if let Ok(value) = std::env::var("SEQD_SOCKET") && !value.trim().is_empty() { return PathBuf::from(value); } PathBuf::from("/tmp/seqd.sock") } fn build_request(action: SeqRpcAction) -> Result<RpcRequest> { match action { SeqRpcAction::Ping(ids) => Ok(with_ids(RpcRequest::new("ping"), ids)), SeqRpcAction::AppState(ids) => Ok(with_ids(RpcRequest::new("app_state"), ids)), SeqRpcAction::Perf(ids) => Ok(with_ids(RpcRequest::new("perf"), ids)), SeqRpcAction::OpenApp(opts) => Ok(build_open_app("open_app", opts)), SeqRpcAction::OpenAppToggle(opts) => Ok(build_open_app("open_app_toggle", opts)), SeqRpcAction::Screenshot(opts) => Ok(build_screenshot(opts)), SeqRpcAction::Rpc(opts) => build_raw(opts), } } fn build_open_app(op: &str, opts: SeqRpcOpenAppOpts) -> RpcRequest { let mut req = with_ids(RpcRequest::new(op), opts.ids); req.args = Some(json!({ "name": opts.name })); req } fn build_screenshot(opts: SeqRpcScreenshotOpts) -> RpcRequest { let mut req = with_ids(RpcRequest::new("screenshot"), opts.ids); req.args = Some(json!({ "path": opts.path })); req } fn build_raw(opts: SeqRpcRawOpts) -> Result<RpcRequest> { let mut req = with_ids(RpcRequest::new(opts.op), opts.ids); if let Some(args_json) = opts.args_json { let parsed: Value = serde_json::from_str(&args_json) .with_context(|| format!("failed to parse --args-json as JSON: {}", args_json))?; req.args = Some(parsed); } Ok(req) } fn with_ids(mut req: RpcRequest, ids: SeqRpcIdOpts) -> RpcRequest { req.request_id = ids.request_id; req.run_id = ids.run_id; req.tool_call_id = ids.tool_call_id; req } ================================================ FILE: src/server.rs ================================================ use std::{ collections::HashMap, convert::Infallible, net::SocketAddr, path::Path, pin::Pin, sync::{Arc, mpsc as std_mpsc}, time::Duration, }; use anyhow::{Context, Result}; use axum::{ Router, extract::{Json as AxumJson, Path as AxumPath, Query, State}, http::{Method, StatusCode}, response::{ IntoResponse, Json, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; use futures::{Stream, StreamExt}; use notify::RecursiveMode; use notify_debouncer_mini::new_debouncer; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::{RwLock, mpsc}; use tokio_stream::wrappers::BroadcastStream; use tower_http::cors::{Any, CorsLayer}; use crate::{ ai, cli::DaemonOpts, config::{self, Config, ServerConfig}, daemon_snapshot, log_store::{self, LogEntry, LogQuery}, running, screen::ScreenBroadcaster, servers::{LogLine, ManagedServer, ServerSnapshot}, skills, supervisor, terminal, }; const LOG_BUFFER_CAPACITY: usize = 2048; type ServerStore = Arc<RwLock<HashMap<String, Arc<ManagedServer>>>>; /// Unified process snapshot returned by GET /processes. #[derive(Debug, Clone, Serialize, Deserialize)] struct ProcessSnapshot { name: String, source: String, project: Option<String>, command: String, status: String, pid: Option<u32>, port: Option<u16>, #[serde(rename = "startedAt")] started_at: Option<u128>, } #[derive(Clone)] struct AppState { screen: ScreenBroadcaster, servers: ServerStore, } type DynSseStream = dyn Stream<Item = std::result::Result<Event, Infallible>> + Send; pub async fn run(opts: DaemonOpts) -> Result<()> { let screen = ScreenBroadcaster::with_mock_stream(opts.frame_buffer, opts.fps); // Load configuration for managed servers. let config_path = opts .config .clone() .unwrap_or_else(config::default_config_path); let mut cfg: Config = config::load_or_default(&config_path); tracing::info!( path = %config_path.display(), server_count = cfg.servers.len(), "loaded flow config" ); if let Some(version) = cfg.version { tracing::debug!(version, "config version detected"); } terminal::maybe_enable_terminal_tracing(&cfg.options); let servers_store: ServerStore = Arc::new(RwLock::new(HashMap::new())); sync_servers(&servers_store, std::mem::take(&mut cfg.servers)).await; if let Some(stream) = cfg.stream.as_ref() { tracing::info!( provider = %stream.provider, hotkey = %stream.hotkey.as_deref().unwrap_or(""), toggle_url = %stream.toggle_url.as_deref().unwrap_or(""), "stream config detected" ); } let state = AppState { screen, servers: Arc::clone(&servers_store), }; let (reload_tx, mut reload_rx) = mpsc::channel(4); if let Err(err) = spawn_config_watcher(&config_path, reload_tx.clone()) { tracing::warn!(?err, "failed to watch config for changes"); } let servers_for_reload = Arc::clone(&servers_store); let config_path_for_reload = config_path.clone(); tokio::spawn(async move { while reload_rx.recv().await.is_some() { if let Err(err) = reload_config(&config_path_for_reload, &servers_for_reload).await { tracing::warn!(?err, "config reload failed"); } } }); let cors = CorsLayer::new() .allow_origin(Any) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_headers(Any); let router = Router::new() .route("/health", get(health)) .route("/codex/skills", get(codex_skills)) .route("/codex/eval", get(codex_eval)) .route("/codex/resolve", post(codex_resolve)) .route("/codex/skills/sync", post(codex_skills_sync)) .route("/codex/skills/reload", post(codex_skills_reload)) .route("/daemons", get(daemons)) .route("/daemons/:name/start", post(daemon_start)) .route("/daemons/:name/stop", post(daemon_stop)) .route("/daemons/:name/restart", post(daemon_restart)) .route("/screen/latest", get(screen_latest)) .route("/screen/stream", get(screen_stream)) .route("/servers", get(servers_list)) .route("/logs", get(all_logs)) .route("/servers/:name/logs", get(server_logs)) .route("/servers/:name/logs/stream", get(server_logs_stream)) // Unified process management endpoints .route("/processes", get(processes_list)) .route("/processes/:name/start", post(process_start)) .route("/processes/:name/stop", post(process_stop)) .route("/processes/:name/restart", post(process_restart)) .route("/processes/:name/logs/stream", get(process_logs_stream)) // Log ingestion endpoints .route("/logs/ingest", post(logs_ingest)) .route("/logs/query", get(logs_query)) .layer(cors) .with_state(state); let addr = SocketAddr::from((opts.host, opts.port)); tracing::info!( "flowd listening on http://{addr} (mock fps: {}, buffer: {}, config: {})", opts.fps, opts.frame_buffer, config_path.display(), ); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, router) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } async fn health() -> impl IntoResponse { Json(json!({ "status": "ok", "message": "flow daemon ready" })) } #[derive(Debug, Deserialize)] struct CodexSkillsQuery { path: Option<String>, #[serde(default = "default_codex_skills_limit")] limit: usize, } #[derive(Debug, Deserialize)] struct CodexEvalQuery { path: Option<String>, #[serde(default = "default_codex_eval_limit")] limit: usize, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodexSkillsSyncRequest { path: Option<String>, #[serde(default)] skills: Vec<String>, #[serde(default)] force: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodexSkillsReloadRequest { path: Option<String>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodexResolveRequest { path: Option<String>, query: String, #[serde(default)] exact_cwd: bool, } fn default_codex_skills_limit() -> usize { 12 } fn default_codex_eval_limit() -> usize { 200 } fn resolve_codex_skills_target(path: Option<&str>) -> PathBuf { let candidate = path .map(config::expand_path) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| config::expand_path("~"))); if candidate.is_absolute() { candidate } else { std::env::current_dir() .unwrap_or_else(|_| config::expand_path("~")) .join(candidate) } } async fn codex_skills(Query(query): Query<CodexSkillsQuery>) -> impl IntoResponse { let target_path = resolve_codex_skills_target(query.path.as_deref()); let limit = query.limit.clamp(1, 50); let result = tokio::task::spawn_blocking(move || { ai::codex_skills_dashboard_snapshot(&target_path, limit) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex skills task failed: {err}") })), ) .into_response(), } } async fn codex_eval(Query(query): Query<CodexEvalQuery>) -> impl IntoResponse { let target_path = resolve_codex_skills_target(query.path.as_deref()); let limit = query.limit.clamp(20, 1000); let result = tokio::task::spawn_blocking(move || ai::codex_eval_snapshot(&target_path, limit)) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex eval task failed: {err}") })), ) .into_response(), } } async fn codex_resolve(AxumJson(payload): AxumJson<CodexResolveRequest>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { ai::codex_resolve_inspector(payload.path, payload.query, payload.exact_cwd) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex resolve task failed: {err}") })), ) .into_response(), } } async fn codex_skills_sync(AxumJson(payload): AxumJson<CodexSkillsSyncRequest>) -> impl IntoResponse { let target_path = resolve_codex_skills_target(payload.path.as_deref()); let result = tokio::task::spawn_blocking(move || { let installed = ai::codex_skill_source_sync(&target_path, &payload.skills, payload.force)?; Ok::<_, anyhow::Error>(json!({ "targetPath": target_path.display().to_string(), "installed": installed, })) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(snapshot)).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex skills sync task failed: {err}") })), ) .into_response(), } } async fn codex_skills_reload( AxumJson(payload): AxumJson<CodexSkillsReloadRequest>, ) -> impl IntoResponse { let target_path = resolve_codex_skills_target(payload.path.as_deref()); let result = tokio::task::spawn_blocking(move || { let reloaded = skills::reload_codex_skills_for_cwd(&target_path)?; Ok::<_, anyhow::Error>(json!({ "targetPath": target_path.display().to_string(), "reloaded": reloaded, })) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(snapshot)).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("codex skills reload task failed: {err}") })), ) .into_response(), } } async fn daemons() -> impl IntoResponse { let result = tokio::task::spawn_blocking(|| daemon_snapshot::load_daemon_snapshot(None)).await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("daemon snapshot task failed: {err}") })), ) .into_response(), } } async fn daemon_start(AxumPath(name): AxumPath<String>) -> impl IntoResponse { daemon_action_response(name, daemon_snapshot::FlowDaemonAction::Start).await } async fn daemon_stop(AxumPath(name): AxumPath<String>) -> impl IntoResponse { daemon_action_response(name, daemon_snapshot::FlowDaemonAction::Stop).await } async fn daemon_restart(AxumPath(name): AxumPath<String>) -> impl IntoResponse { daemon_action_response(name, daemon_snapshot::FlowDaemonAction::Restart).await } async fn daemon_action_response( name: String, action: daemon_snapshot::FlowDaemonAction, ) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { daemon_snapshot::run_daemon_action(&name, action, None) }) .await; match result { Ok(Ok(snapshot)) => (StatusCode::OK, Json(json!(snapshot))).into_response(), Ok(Err(err)) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("daemon action task failed: {err}") })), ) .into_response(), } } async fn screen_latest(State(state): State<AppState>) -> impl IntoResponse { match state.screen.latest().await { Some(frame) => (StatusCode::OK, Json(frame)).into_response(), None => StatusCode::NO_CONTENT.into_response(), } } async fn screen_stream( State(state): State<AppState>, ) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> { let stream = BroadcastStream::new(state.screen.subscribe()).filter_map(|result| async move { match result { Ok(frame) => match serde_json::to_string(&frame) { Ok(payload) => Some(Ok(Event::default().data(payload))), Err(err) => { tracing::error!(?err, "failed to serialize screen frame"); None } }, Err(err) => { tracing::warn!(?err, "screen broadcast channel dropped event"); None } } }); Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(5)) .text(":flowd keep-alive"), ) } async fn servers_list(State(state): State<AppState>) -> impl IntoResponse { let servers = state.servers.read().await; let futures_iter = servers .values() .cloned() .map(|server| async move { server.snapshot().await }); let snapshots: Vec<ServerSnapshot> = futures::future::join_all(futures_iter).await; (StatusCode::OK, Json(snapshots)).into_response() } #[derive(Debug, Deserialize)] struct LogsQuery { #[serde(default = "default_logs_limit")] limit: usize, } fn default_logs_limit() -> usize { 512 } async fn server_logs( State(state): State<AppState>, AxumPath(name): AxumPath<String>, Query(query): Query<LogsQuery>, ) -> impl IntoResponse { let server = { let guard = state.servers.read().await; guard.get(&name).cloned() }; match server { Some(server) => { let lines: Vec<LogLine> = server.recent_logs(query.limit).await; (StatusCode::OK, Json(lines)).into_response() } None => ( StatusCode::NOT_FOUND, Json(json!({ "error": format!("unknown server {name}") })), ) .into_response(), } } async fn all_logs( State(state): State<AppState>, Query(query): Query<LogsQuery>, ) -> impl IntoResponse { let servers: Vec<_> = { let guard = state.servers.read().await; guard.values().cloned().collect() }; let mut entries: Vec<LogLine> = Vec::new(); for server in servers { let mut lines = server.recent_logs(query.limit).await; entries.append(&mut lines); } entries.sort_by_key(|line| line.timestamp_ms); if entries.len() > query.limit { entries = entries.split_off(entries.len() - query.limit); } (StatusCode::OK, Json(entries)).into_response() } async fn server_logs_stream( State(state): State<AppState>, AxumPath(name): AxumPath<String>, ) -> Sse<Pin<Box<DynSseStream>>> { let server = { let guard = state.servers.read().await; guard.get(&name).cloned() }; let (stream, enable_keep_alive) = match server { Some(server) => { let receiver = server.subscribe(); let stream = BroadcastStream::new(receiver).filter_map(|result| async move { match result { Ok(line) => match serde_json::to_string(&line) { Ok(payload) => Some(Ok(Event::default().data(payload))), Err(err) => { tracing::error!(?err, "failed to serialize log line"); None } }, Err(err) => { tracing::warn!(?err, "server log broadcast channel dropped event"); None } } }); (Box::pin(stream) as Pin<Box<DynSseStream>>, true) } None => { let stream = futures::stream::once(async move { Ok(Event::default().data( serde_json::to_string(&json!({ "error": format!("unknown server {name}") })) .unwrap_or_else(|_| "{\"error\":\"unknown server\"}".to_string()), )) }); (Box::pin(stream) as Pin<Box<DynSseStream>>, false) } }; let sse = Sse::new(stream); if enable_keep_alive { sse.keep_alive( KeepAlive::new() .interval(Duration::from_secs(5)) .text(":flowd log keep-alive"), ) } else { sse } } // ============================================================================ // Unified Process Management Endpoints // ============================================================================ /// GET /processes - Returns all running processes from servers, daemons, and tasks. async fn processes_list(State(state): State<AppState>) -> impl IntoResponse { let mut snapshots: Vec<ProcessSnapshot> = Vec::new(); // 1. Managed servers from ServerStore { let servers = state.servers.read().await; let futures_iter = servers .values() .cloned() .map(|server| async move { server.snapshot().await }); let server_snapshots: Vec<ServerSnapshot> = futures::future::join_all(futures_iter).await; for s in server_snapshots { snapshots.push(ProcessSnapshot { name: s.name.clone(), source: "server".to_string(), project: None, command: if s.args.is_empty() { s.command.clone() } else { format!("{} {}", s.command, s.args.join(" ")) }, status: s.status.clone(), pid: s.pid, port: s.port, started_at: None, }); } } // 2. Supervisor daemons via IPC if let Ok(socket_path) = supervisor::resolve_socket_path(None) { if socket_path.exists() { let ipc_result = tokio::task::spawn_blocking(move || { let request = supervisor::IpcRequest { action: supervisor::SupervisorIpcAction::Status { config_path: None, }, }; supervisor::send_request(&socket_path, &request) }) .await; if let Ok(Ok(response)) = ipc_result { if let Some(daemons) = response.daemons { for d in daemons { // Skip duplicates already covered by managed servers if snapshots.iter().any(|s| s.name == d.name) { continue; } let port = d .health_url .as_deref() .and_then(|url| url.rsplit(':').next()) .and_then(|port_path| { port_path.split('/').next().and_then(|p| p.parse().ok()) }); snapshots.push(ProcessSnapshot { name: d.name, source: "daemon".to_string(), project: None, command: d .description .unwrap_or_default(), status: if d.running { "running".to_string() } else { "stopped".to_string() }, pid: d.pid, port, started_at: None, }); } } } } } // 3. Running tasks from the persistent running-process store let task_result = tokio::task::spawn_blocking(|| running::load_running_processes()).await; if let Ok(Ok(processes)) = task_result { for procs in processes.projects.values() { for p in procs { snapshots.push(ProcessSnapshot { name: p.task_name.clone(), source: "task".to_string(), project: p.project_name.clone(), command: p.command.clone(), status: "running".to_string(), pid: Some(p.pid), port: None, started_at: Some(p.started_at), }); } } } (StatusCode::OK, Json(snapshots)).into_response() } /// POST /processes/:name/start async fn process_start( State(state): State<AppState>, AxumPath(name): AxumPath<String>, ) -> impl IntoResponse { // Try managed server first { let servers = state.servers.read().await; if let Some(server) = servers.get(&name) { return match server.start().await { Ok(()) => (StatusCode::OK, Json(json!({ "ok": true, "message": format!("{name} started") }))).into_response(), Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() }))).into_response(), }; } } // Try supervisor daemon let daemon_name = name.clone(); let ipc_result = tokio::task::spawn_blocking(move || { let socket_path = supervisor::resolve_socket_path(None)?; let request = supervisor::IpcRequest { action: supervisor::SupervisorIpcAction::StartDaemon { name: daemon_name, config_path: None, }, }; supervisor::send_request(&socket_path, &request) }) .await; match ipc_result { Ok(Ok(resp)) if resp.ok => { (StatusCode::OK, Json(json!({ "ok": true, "message": resp.message }))).into_response() } Ok(Ok(resp)) => { (StatusCode::BAD_REQUEST, Json(json!({ "error": resp.message }))).into_response() } _ => { (StatusCode::NOT_FOUND, Json(json!({ "error": format!("unknown process {name}") }))).into_response() } } } /// POST /processes/:name/stop async fn process_stop( State(state): State<AppState>, AxumPath(name): AxumPath<String>, ) -> impl IntoResponse { // Try managed server first { let servers = state.servers.read().await; if let Some(server) = servers.get(&name) { return match server.stop().await { Ok(()) => (StatusCode::OK, Json(json!({ "ok": true, "message": format!("{name} stopped") }))).into_response(), Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() }))).into_response(), }; } } // Try supervisor daemon let daemon_name = name.clone(); let ipc_result = tokio::task::spawn_blocking(move || { let socket_path = supervisor::resolve_socket_path(None)?; let request = supervisor::IpcRequest { action: supervisor::SupervisorIpcAction::StopDaemon { name: daemon_name, config_path: None, }, }; supervisor::send_request(&socket_path, &request) }) .await; match ipc_result { Ok(Ok(resp)) if resp.ok => { (StatusCode::OK, Json(json!({ "ok": true, "message": resp.message }))).into_response() } Ok(Ok(resp)) => { (StatusCode::BAD_REQUEST, Json(json!({ "error": resp.message }))).into_response() } _ => { (StatusCode::NOT_FOUND, Json(json!({ "error": format!("unknown process {name}") }))).into_response() } } } /// POST /processes/:name/restart async fn process_restart( State(state): State<AppState>, AxumPath(name): AxumPath<String>, ) -> impl IntoResponse { // Try managed server: stop then start { let servers = state.servers.read().await; if let Some(server) = servers.get(&name) { let _ = server.stop().await; tokio::time::sleep(Duration::from_millis(300)).await; return match server.start().await { Ok(()) => (StatusCode::OK, Json(json!({ "ok": true, "message": format!("{name} restarted") }))).into_response(), Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() }))).into_response(), }; } } // Try supervisor daemon let daemon_name = name.clone(); let ipc_result = tokio::task::spawn_blocking(move || { let socket_path = supervisor::resolve_socket_path(None)?; let request = supervisor::IpcRequest { action: supervisor::SupervisorIpcAction::RestartDaemon { name: daemon_name, config_path: None, }, }; supervisor::send_request(&socket_path, &request) }) .await; match ipc_result { Ok(Ok(resp)) if resp.ok => { (StatusCode::OK, Json(json!({ "ok": true, "message": resp.message }))).into_response() } Ok(Ok(resp)) => { (StatusCode::BAD_REQUEST, Json(json!({ "error": resp.message }))).into_response() } _ => { (StatusCode::NOT_FOUND, Json(json!({ "error": format!("unknown process {name}") }))).into_response() } } } /// GET /processes/:name/logs/stream - SSE log stream for any process type. async fn process_logs_stream( State(state): State<AppState>, AxumPath(name): AxumPath<String>, ) -> Sse<Pin<Box<DynSseStream>>> { // Check if it's a managed server — delegate to existing broadcast let server = { let guard = state.servers.read().await; guard.get(&name).cloned() }; if let Some(server) = server { let receiver = server.subscribe(); let stream = BroadcastStream::new(receiver).filter_map(|result| async move { match result { Ok(line) => match serde_json::to_string(&line) { Ok(payload) => Some(Ok(Event::default().data(payload))), Err(_) => None, }, Err(_) => None, } }); return Sse::new(Box::pin(stream) as Pin<Box<DynSseStream>>).keep_alive( KeepAlive::new() .interval(Duration::from_secs(5)) .text(":flowd process log keep-alive"), ); } // For daemons/tasks: tail log files via polling let log_path = find_process_log_path(&name).await; let stream: Pin<Box<DynSseStream>> = match log_path { Some(path) => { let stream = futures::stream::unfold( (path, 0u64), |(path, last_pos)| async move { tokio::time::sleep(Duration::from_millis(500)).await; let metadata = tokio::fs::metadata(&path).await.ok()?; let file_len = metadata.len(); if file_len <= last_pos { return Some((Vec::new(), (path, last_pos))); } let mut file = tokio::fs::File::open(&path).await.ok()?; tokio::io::AsyncSeekExt::seek( &mut file, std::io::SeekFrom::Start(last_pos), ) .await .ok()?; let mut buf = vec![0u8; (file_len - last_pos).min(65536) as usize]; let n = tokio::io::AsyncReadExt::read(&mut file, &mut buf).await.ok()?; buf.truncate(n); let new_pos = last_pos + n as u64; let text = String::from_utf8_lossy(&buf).to_string(); let events: Vec<std::result::Result<Event, Infallible>> = text .lines() .filter(|l| !l.is_empty()) .map(|line| { Ok(Event::default().data( serde_json::to_string(&json!({ "line": line, "stream": "stdout", "timestamp_ms": running::now_ms(), })) .unwrap_or_default(), )) }) .collect(); Some((events, (path, new_pos))) }, ) .flat_map(futures::stream::iter); Box::pin(stream) } None => { let stream = futures::stream::once(async move { Ok(Event::default().data( json!({ "error": format!("no logs found for {name}") }).to_string(), )) }); Box::pin(stream) } }; Sse::new(stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(5)) .text(":flowd process log keep-alive"), ) } /// Find log file path for a daemon or task by name. async fn find_process_log_path(name: &str) -> Option<std::path::PathBuf> { let state_dir = config::global_state_dir(); // Check daemon stdout log let daemon_log = state_dir.join("daemons").join(name).join("stdout.log"); if tokio::fs::metadata(&daemon_log).await.is_ok() { return Some(daemon_log); } // Check task log files under ~/.config/flow/logs/ let logs_dir = state_dir.join("logs"); if let Ok(mut entries) = tokio::fs::read_dir(&logs_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { let project_dir = entry.path(); let task_log = project_dir.join(format!("{name}.log")); if tokio::fs::metadata(&task_log).await.is_ok() { return Some(task_log); } } } None } async fn reload_config(path: &Path, servers: &ServerStore) -> Result<()> { let mut cfg = config::load(path) .with_context(|| format!("failed to reload config at {}", path.display()))?; tracing::info!(path = %path.display(), "config changed; reloading"); sync_servers(servers, std::mem::take(&mut cfg.servers)).await; if let Some(stream) = cfg.stream { tracing::info!( provider = %stream.provider, hotkey = %stream.hotkey.as_deref().unwrap_or(""), toggle_url = %stream.toggle_url.as_deref().unwrap_or(""), "stream config updated" ); } Ok(()) } async fn sync_servers(store: &ServerStore, configs: Vec<ServerConfig>) { let mut desired: HashMap<String, ServerConfig> = HashMap::new(); for cfg in configs.into_iter() { desired.insert(cfg.name.clone(), cfg); } let mut to_stop: Vec<Arc<ManagedServer>> = Vec::new(); let mut to_start: Vec<Arc<ManagedServer>> = Vec::new(); { let mut guard = store.write().await; guard.retain(|name, server| { if !desired.contains_key(name) { to_stop.push(server.clone()); false } else { true } }); for (name, cfg) in desired.into_iter() { if let Some(existing) = guard.get(&name) { if existing.config() == &cfg { continue; } to_stop.push(existing.clone()); guard.remove(&name); } let managed = ManagedServer::new(cfg.clone(), LOG_BUFFER_CAPACITY); if cfg.autostart { to_start.push(managed.clone()); } guard.insert(name, managed); } } for server in to_stop { if let Err(err) = server.stop().await { tracing::warn!( ?err, name = server.config().name, "failed to stop managed server during reload" ); } } for server in to_start { tokio::spawn(async move { if let Err(err) = server.start().await { tracing::error!( ?err, server = server.config().name, "failed to start managed server" ); } }); } } fn spawn_config_watcher(path: &Path, tx: mpsc::Sender<()>) -> notify::Result<()> { let target = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let watch_root = target .parent() .map(Path::to_path_buf) .unwrap_or_else(|| target.clone()); std::thread::spawn(move || { let (event_tx, event_rx) = std_mpsc::channel(); let mut debouncer = match new_debouncer(Duration::from_millis(250), event_tx) { Ok(debouncer) => debouncer, Err(err) => { tracing::error!(?err, "failed to initialize config watcher"); return; } }; if let Err(err) = debouncer .watcher() .watch(&watch_root, RecursiveMode::NonRecursive) { tracing::error!(?err, path = %watch_root.display(), "failed to watch config directory"); return; } while let Ok(result) = event_rx.recv() { match result { Ok(events) => { let should_reload = events.iter().any(|event| same_file(&target, &event.path)); if should_reload && tx.blocking_send(()).is_err() { break; } } Err(err) => tracing::warn!(?err, "config watcher error"), } } }); Ok(()) } fn same_file(a: &Path, b: &Path) -> bool { if a == b { return true; } if let Ok(canon) = b.canonicalize() { if canon == a { return true; } } a.file_name() .is_some_and(|name| Some(name) == b.file_name()) } async fn shutdown_signal() { if tokio::signal::ctrl_c().await.is_ok() { tracing::info!("shutdown signal received"); } } // ============================================================================ // Log Ingestion Endpoints // ============================================================================ /// Request body for log ingestion - single entry or batch. #[derive(Debug, Deserialize)] #[serde(untagged)] enum IngestRequest { Single(LogEntry), Batch(Vec<LogEntry>), } /// POST /logs/ingest - Ingest log entries into the database. async fn logs_ingest(Json(payload): Json<IngestRequest>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let mut conn = match log_store::open_log_db() { Ok(c) => c, Err(e) => return Err(e), }; match payload { IngestRequest::Single(entry) => { let id = log_store::insert_log(&conn, &entry)?; Ok(json!({ "inserted": 1, "ids": [id] })) } IngestRequest::Batch(entries) => { let ids = log_store::insert_logs(&mut conn, &entries)?; Ok(json!({ "inserted": ids.len(), "ids": ids })) } } }) .await; match result { Ok(Ok(response)) => (StatusCode::OK, Json(response)).into_response(), Ok(Err(err)) => { tracing::error!(?err, "log ingest failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response() } Err(err) => { tracing::error!(?err, "log ingest task panicked"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "internal error" })), ) .into_response() } } } /// GET /logs/query - Query stored logs with filters. async fn logs_query(Query(query): Query<LogQuery>) -> impl IntoResponse { let result = tokio::task::spawn_blocking(move || { let conn = log_store::open_log_db()?; log_store::query_logs(&conn, &query) }) .await; match result { Ok(Ok(entries)) => (StatusCode::OK, Json(entries)).into_response(), Ok(Err(err)) => { tracing::error!(?err, "log query failed"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": err.to_string() })), ) .into_response() } Err(err) => { tracing::error!(?err, "log query task panicked"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "internal error" })), ) .into_response() } } } ================================================ FILE: src/servers.rs ================================================ use std::{ collections::VecDeque, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tokio::{ io::{AsyncBufReadExt, BufReader}, process::Command, sync::{Mutex, RwLock, broadcast, mpsc}, }; use crate::config::ServerConfig; /// Origin of a log line (stdout or stderr). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LogStream { Stdout, Stderr, } /// Single log entry from a managed server process. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogLine { /// Name of the server that produced this line. pub server: String, /// Milliseconds since UNIX epoch when the line was captured. pub timestamp_ms: u128, /// Which stream the line came from. pub stream: LogStream, /// The raw text of the log line. pub line: String, } #[derive(Debug)] enum ProcessState { Idle, Starting, Running { pid: u32 }, Exited { code: Option<i32> }, Failed { error: String }, } #[derive(Debug, Clone, Copy)] enum ServerControl { Terminate, } /// Snapshot of the current state of a managed server, suitable for JSON APIs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerSnapshot { pub name: String, pub command: String, pub args: Vec<String>, pub port: Option<u16>, pub working_dir: Option<std::path::PathBuf>, pub autostart: bool, pub status: String, pub pid: Option<u32>, pub exit_code: Option<i32>, } /// In-process supervisor for a single child HTTP server defined in the config. #[derive(Debug)] pub struct ManagedServer { cfg: ServerConfig, state: RwLock<ProcessState>, log_tx: broadcast::Sender<LogLine>, log_buffer: RwLock<VecDeque<LogLine>>, log_buffer_capacity: usize, control: Mutex<Option<mpsc::Sender<ServerControl>>>, } impl ManagedServer { pub fn new(cfg: ServerConfig, log_buffer_capacity: usize) -> Arc<Self> { let (log_tx, _) = broadcast::channel(1024); Arc::new(Self { cfg, state: RwLock::new(ProcessState::Idle), log_tx, log_buffer: RwLock::new(VecDeque::with_capacity(log_buffer_capacity)), log_buffer_capacity, control: Mutex::new(None), }) } pub fn config(&self) -> &ServerConfig { &self.cfg } pub fn subscribe(&self) -> broadcast::Receiver<LogLine> { self.log_tx.subscribe() } pub async fn snapshot(&self) -> ServerSnapshot { let state = self.state.read().await; let (status, pid, exit_code) = match &*state { ProcessState::Idle => ("idle".to_string(), None, None), ProcessState::Starting => ("starting".to_string(), None, None), ProcessState::Running { pid } => ("running".to_string(), Some(*pid), None), ProcessState::Exited { code } => ("exited".to_string(), None, *code), ProcessState::Failed { error } => (format!("failed: {error}"), None, None), }; ServerSnapshot { name: self.cfg.name.clone(), command: self.cfg.command.clone(), args: self.cfg.args.clone(), port: self.cfg.port, working_dir: self.cfg.working_dir.clone(), autostart: self.cfg.autostart, status, pid, exit_code, } } pub async fn recent_logs(&self, limit: usize) -> Vec<LogLine> { let guard = self.log_buffer.read().await; let len = guard.len(); let start = len.saturating_sub(limit); guard.iter().skip(start).cloned().collect() } /// Spawn the configured process and begin capturing stdout/stderr. /// /// This method returns immediately after the process has been started; a /// background task monitors for process exit. pub async fn start(self: &Arc<Self>) -> Result<()> { { let state = self.state.read().await; if matches!( *state, ProcessState::Starting | ProcessState::Running { .. } ) { return Ok(()); } } { let mut state = self.state.write().await; *state = ProcessState::Starting; } let mut cmd = Command::new(&self.cfg.command); cmd.args(&self.cfg.args); if let Some(dir) = &self.cfg.working_dir { cmd.current_dir(dir); } if !self.cfg.env.is_empty() { cmd.envs(self.cfg.env.clone()); } cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); let mut child = cmd .spawn() .with_context(|| format!("failed to spawn managed server {}", self.cfg.name))?; { let pid = child.id().unwrap_or(0); let mut state = self.state.write().await; *state = ProcessState::Running { pid }; } let (control_tx, mut control_rx) = mpsc::channel(1); { let mut guard = self.control.lock().await; *guard = Some(control_tx); } let server = Arc::clone(self); // stdout task if let Some(stdout) = child.stdout.take() { Self::spawn_log_task(Arc::clone(&server), stdout, LogStream::Stdout); } // stderr task if let Some(stderr) = child.stderr.take() { Self::spawn_log_task(server.clone(), stderr, LogStream::Stderr); } // wait for exit tokio::spawn(async move { let status = tokio::select! { status = child.wait() => status, ctrl = control_rx.recv() => { if matches!(ctrl, Some(ServerControl::Terminate)) { if let Err(err) = child.kill().await { tracing::warn!(?err, server = server.cfg.name, "failed to terminate server child"); } } child.wait().await } }; { let mut guard = server.control.lock().await; *guard = None; } let mut state = server.state.write().await; match status { Ok(status) => { *state = ProcessState::Exited { code: status.code(), } } Err(err) => { *state = ProcessState::Failed { error: err.to_string(), } } } }); Ok(()) } pub async fn stop(&self) -> Result<()> { let tx = { self.control.lock().await.clone() }; if let Some(tx) = tx { let _ = tx.send(ServerControl::Terminate).await; } Ok(()) } fn spawn_log_task<R>(server: Arc<Self>, reader: R, stream: LogStream) where R: tokio::io::AsyncRead + Unpin + Send + 'static, { tokio::spawn(async move { let mut lines = BufReader::new(reader).lines(); while let Ok(Some(line)) = lines.next_line().await { let entry = LogLine { server: server.cfg.name.clone(), timestamp_ms: current_epoch_ms(), stream: stream.clone(), line, }; server.push_log(entry).await; } }); } async fn push_log(&self, line: LogLine) { // broadcast; ignore errors if there are no subscribers let _ = self.log_tx.send(line.clone()); let mut buf = self.log_buffer.write().await; if buf.len() == self.log_buffer_capacity { buf.pop_front(); } buf.push_back(line); } } fn current_epoch_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0) } ================================================ FILE: src/servers_tui.rs ================================================ use std::{ io, time::{Duration, Instant}, }; use anyhow::{Context, Result}; use crossterm::{ event::{self, Event as CEvent, KeyCode, KeyEvent}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{ Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, }; use crate::{ cli::ServersOpts, servers::{LogLine, LogStream, ServerSnapshot}, }; const LOG_LIMIT: usize = 512; pub fn run(opts: ServersOpts) -> Result<()> { let base_url = format!("http://{}:{}", opts.host, opts.port); let client = reqwest::blocking::Client::new(); // Set up terminal enable_raw_mode().context("failed to enable raw mode")?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).context("failed to create terminal backend")?; let app_result = run_app(&mut terminal, client, base_url); // Restore terminal state on exit disable_raw_mode().ok(); let _ = terminal.show_cursor(); drop(terminal); let mut stdout = io::stdout(); execute!(stdout, LeaveAlternateScreen).ok(); app_result } struct App { client: reqwest::blocking::Client, base_url: String, servers: Vec<ServerSnapshot>, selected: usize, logs: Vec<LogLine>, log_scroll: u16, focus_server: bool, last_servers_refresh: Instant, last_logs_refresh: Instant, } impl App { fn new(client: reqwest::blocking::Client, base_url: String) -> Result<Self> { let mut app = Self { client, base_url, servers: Vec::new(), selected: 0, logs: Vec::new(), log_scroll: 0, focus_server: true, last_servers_refresh: Instant::now(), last_logs_refresh: Instant::now(), }; app.refresh_servers()?; app.refresh_logs()?; Ok(app) } fn selected_server_name(&self) -> Option<&str> { self.servers.get(self.selected).map(|s| s.name.as_str()) } fn refresh_servers(&mut self) -> Result<()> { let url = format!("{}/servers", self.base_url); let resp = self .client .get(&url) .send() .with_context(|| format!("failed to GET {url}"))?; if !resp.status().is_success() { anyhow::bail!( "daemon responded with {} when fetching servers", resp.status() ); } let servers = resp .json::<Vec<ServerSnapshot>>() .context("failed to decode /servers response")?; self.servers = servers; if self.selected >= self.servers.len() { self.selected = self.servers.len().saturating_sub(1); } self.last_servers_refresh = Instant::now(); Ok(()) } fn refresh_logs(&mut self) -> Result<()> { let request = if self.focus_server { let name = match self.selected_server_name() { Some(name) => name, None => { self.logs.clear(); self.focus_server = false; self.last_logs_refresh = Instant::now(); return Ok(()); } }; format!("{}/servers/{}/logs", self.base_url, name) } else { format!("{}/logs", self.base_url) }; let resp = self .client .get(&request) .query(&[("limit", LOG_LIMIT)]) .send() .with_context(|| format!("failed to GET {request}"))?; if resp.status().is_success() { let logs = resp .json::<Vec<LogLine>>() .context("failed to decode logs response")?; self.logs = logs; } else { self.logs.clear(); } self.last_logs_refresh = Instant::now(); Ok(()) } fn maybe_refresh(&mut self) -> Result<()> { let now = Instant::now(); if now.duration_since(self.last_servers_refresh) > Duration::from_secs(5) { let _ = self.refresh_servers(); } if now.duration_since(self.last_logs_refresh) > Duration::from_secs(1) { let _ = self.refresh_logs(); } Ok(()) } fn select_next(&mut self) -> Result<()> { if !self.servers.is_empty() && self.selected + 1 < self.servers.len() { self.selected += 1; self.log_scroll = 0; self.refresh_logs()?; } Ok(()) } fn select_prev(&mut self) -> Result<()> { if !self.servers.is_empty() && self.selected > 0 { self.selected -= 1; self.log_scroll = 0; self.refresh_logs()?; } Ok(()) } fn scroll_down(&mut self) { self.log_scroll = self.log_scroll.saturating_add(1); } fn scroll_up(&mut self) { self.log_scroll = self.log_scroll.saturating_sub(1); } fn toggle_focus(&mut self) -> Result<()> { if self.servers.is_empty() { return Ok(()); } self.focus_server = !self.focus_server; self.log_scroll = 0; self.refresh_logs() } fn show_all_logs(&mut self) -> Result<()> { if self.focus_server { self.focus_server = false; self.log_scroll = 0; self.refresh_logs() } else { Ok(()) } } fn log_scope_label(&self) -> String { if self.focus_server { match self.selected_server_name() { Some(name) => format!("Focused: {}", name), None => "Focused: (none)".to_string(), } } else { "All servers".to_string() } } } fn run_app<B: ratatui::backend::Backend>( terminal: &mut Terminal<B>, client: reqwest::blocking::Client, base_url: String, ) -> Result<()> { let mut app = App::new(client, base_url)?; let tick_rate = Duration::from_millis(250); loop { terminal .draw(|f| draw_ui(f, &app)) .context("failed to draw TUI frame")?; if crossterm::event::poll(tick_rate)? { if let CEvent::Key(key) = event::read()? { if handle_key(&mut app, key)? { break; } } } app.maybe_refresh()?; } Ok(()) } fn handle_key(app: &mut App, key: KeyEvent) -> Result<bool> { match key.code { KeyCode::Char('q') => return Ok(true), KeyCode::Esc => return Ok(true), KeyCode::Down | KeyCode::Char('j') => { app.select_next()?; } KeyCode::Up | KeyCode::Char('k') => { app.select_prev()?; } KeyCode::PageDown | KeyCode::Char('J') => { app.scroll_down(); } KeyCode::PageUp | KeyCode::Char('K') => { app.scroll_up(); } KeyCode::Char('r') => { app.refresh_servers()?; app.refresh_logs()?; } KeyCode::Char('f') => { app.toggle_focus()?; } KeyCode::Char('a') => { app.show_all_logs()?; } _ => {} } Ok(false) } fn draw_ui(f: &mut ratatui::Frame<'_>, app: &App) { let size = f.size(); let layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)]) .split(size); let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(layout[0]); // Servers list let servers_items: Vec<ListItem> = if app.servers.is_empty() { vec![ListItem::new("No servers (check config or daemon)")] } else { app.servers .iter() .map(|s| { let label = match s.port { Some(port) => format!("{}:{} [{}]", s.name, port, s.status), None => format!("{} [{}]", s.name, s.status), }; ListItem::new(label) }) .collect() }; let mut list_state = ListState::default(); if !app.servers.is_empty() { list_state.select(Some(app.selected)); } let servers_list = List::new(servers_items) .block( Block::default() .borders(Borders::ALL) .title("Servers (↑/↓, r = reload, q = quit)"), ) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("▶ "); f.render_stateful_widget(servers_list, chunks[0], &mut list_state); // Logs pane let log_lines: Vec<Line> = if app.logs.is_empty() { vec![Line::from(Span::raw("No logs yet"))] } else { app.logs .iter() .map(|line| { let ts = format_ts(line.timestamp_ms); let stream = match line.stream { LogStream::Stdout => ("OUT", Style::default().fg(Color::Green)), LogStream::Stderr => ("ERR", Style::default().fg(Color::Red)), }; let server_label = Span::styled( format!("{:<12}", line.server), Style::default() .fg(Color::LightCyan) .add_modifier(Modifier::BOLD), ); Line::from(vec![ Span::styled( format!("[{ts}]"), Style::default().add_modifier(Modifier::DIM), ), Span::raw(" "), server_label, Span::raw(" "), Span::styled(stream.0, stream.1.add_modifier(Modifier::BOLD)), Span::raw(" "), Span::raw(line.line.trim_end()), ]) }) .collect() }; let scope = app.log_scope_label(); let title = format!("Logs ({scope}) – PgUp/PgDn scroll • f focus toggle • a all logs"); let logs_widget = Paragraph::new(log_lines) .block(Block::default().borders(Borders::ALL).title(title)) .scroll((app.log_scroll, 0)); f.render_widget(logs_widget, chunks[1]); let help = Paragraph::new(Line::from(vec![ Span::styled("Hub: ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(&app.base_url), Span::raw(" | q quit • r refresh • j/k select • f focus • a all logs"), ])) .block(Block::default().borders(Borders::ALL).title("Help")) .wrap(Wrap { trim: true }); f.render_widget(help, layout[1]); } fn format_ts(ms: u128) -> String { let secs = ms / 1000; let millis = ms % 1000; format!("{secs}.{millis:03}") } ================================================ FILE: src/services.rs ================================================ use std::collections::HashMap; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crossterm::event::{self, Event as CEvent, KeyCode}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use crate::cli::{ServicesAction, ServicesCommand, StripeModeArg, StripeServiceOpts}; use crate::{config, deploy, env}; pub fn run(cmd: ServicesCommand) -> Result<()> { match cmd.action { Some(ServicesAction::Stripe(opts)) => run_stripe(opts), Some(ServicesAction::List) | None => list_services(), } } fn list_services() -> Result<()> { println!("Available service setup flows:"); println!(" stripe - Guided Stripe env setup"); Ok(()) } pub fn maybe_run_stripe_setup( project_root: &Path, flow_cfg: &config::Config, env_name: &str, ) -> Result<()> { let stripe_keys = collect_stripe_keys(flow_cfg); if stripe_keys.is_empty() { return Ok(()); } let required_keys = stripe_keys .iter() .filter(|key| stripe_key_spec(key).required) .cloned() .collect::<Vec<_>>(); if required_keys.is_empty() { return Ok(()); } let existing = fetch_project_env_vars_allow_missing(env_name, &required_keys)?; let missing_required = required_keys .iter() .filter(|key| { existing .get(*key) .map(|value| value.trim().is_empty()) .unwrap_or(true) }) .cloned() .collect::<Vec<_>>(); if missing_required.is_empty() { println!("Stripe env vars already configured; skipping Stripe setup."); return Ok(()); } println!("Stripe env vars missing: {}", missing_required.join(", ")); if !prompt_yes_no("Run Stripe setup now?", true)? { return Ok(()); } let mode_default = default_stripe_mode_for_env(env_name); let mode = prompt_stripe_mode(mode_default)?; run_stripe(StripeServiceOpts { path: Some(project_root.to_path_buf()), environment: Some(env_name.to_string()), mode, force: false, apply: false, no_apply: true, }) } fn run_stripe(opts: StripeServiceOpts) -> Result<()> { let (project_root, flow_path, flow_cfg) = resolve_project_root(opts.path.as_ref())?; let _dir_guard = DirGuard::new(&project_root)?; let project_name = flow_cfg .project_name .clone() .or_else(|| { project_root .file_name() .and_then(|name| name.to_str()) .map(|s| s.to_string()) }) .unwrap_or_else(|| "project".to_string()); let env_name = opts.environment.clone().or_else(|| { flow_cfg .cloudflare .as_ref() .and_then(|cfg| cfg.environment.clone()) }); let env_name = env_name.unwrap_or_else(|| "production".to_string()); println!("Stripe setup"); println!("------------"); println!("Project: {}", project_name); println!("Config: {}", flow_path.display()); println!("Env: {}", env_name); println!( "Mode: {}", match opts.mode { StripeModeArg::Test => "test", StripeModeArg::Live => "live", } ); println!(); let keys = collect_stripe_keys(&flow_cfg); let specs = keys .into_iter() .map(|key| stripe_key_spec(&key)) .collect::<Vec<_>>(); let key_names: Vec<String> = specs.iter().map(|spec| spec.key.clone()).collect(); let existing = fetch_project_env_vars_allow_missing(&env_name, &key_names)?; let has_optional = specs.iter().any(|spec| !spec.required); let include_optional = if has_optional { prompt_yes_no("Set optional Stripe keys?", false)? } else { false }; let mut missing_required = Vec::new(); let mut updated = 0usize; for spec in specs { if !spec.required && !include_optional { continue; } if existing.contains_key(&spec.key) && !opts.force { println!("OK {} already set (use --force to update)", spec.key); continue; } println!(); println!( "{}{}", spec.key, if spec.required { " (required)" } else { "" } ); println!(" {}", spec.description); for line in spec.instructions(opts.mode) { println!(" - {}", line); } let value = if spec.secret { prompt_secret("Enter value (leave blank to skip)")? } else { prompt_line("Enter value (leave blank to skip)", None)? }; let trimmed = value.trim(); if trimmed.is_empty() { if spec.required { missing_required.push(spec.key.clone()); println!(" WARN Skipped required key."); } else { println!(" Skipped."); } continue; } if let Some(prefix) = spec.expected_prefix(opts.mode) { if !trimmed.starts_with(prefix) { println!( " WARN Value does not look like {} (expected prefix: {}).", spec.key, prefix ); } } env::set_project_env_var(&spec.key, trimmed, &env_name, Some(spec.description))?; updated += 1; } println!(); println!("Stripe setup complete. Updated {} key(s).", updated); if !missing_required.is_empty() { println!("Missing required keys:"); for key in &missing_required { println!(" - {}", key); } } if should_apply_env(&opts) { apply_cloudflare_env(&project_root, &flow_cfg)?; } else { println!("Skipped applying envs to Cloudflare."); } Ok(()) } fn apply_cloudflare_env(project_root: &Path, flow_cfg: &config::Config) -> Result<()> { let Some(cf) = flow_cfg.cloudflare.as_ref() else { println!("No [cloudflare] section found; skip apply."); return Ok(()); }; if !is_cloud_source(cf.env_source.as_deref()) { println!("cloudflare.env_source is not set to \"cloud\"; skip apply."); return Ok(()); } deploy::apply_cloudflare_env(project_root, Some(flow_cfg)) } fn should_apply_env(opts: &StripeServiceOpts) -> bool { if opts.apply { return true; } if opts.no_apply { return false; } prompt_yes_no("Apply envs to Cloudflare now?", true).unwrap_or(false) } fn is_cloud_source(source: Option<&str>) -> bool { matches!( source.map(|s| s.to_ascii_lowercase()).as_deref(), Some("cloud") | Some("remote") | Some("myflow") ) } fn fetch_project_env_vars_allow_missing( env_name: &str, keys: &[String], ) -> Result<HashMap<String, String>> { match env::fetch_project_env_vars(env_name, keys) { Ok(values) => Ok(values), Err(err) => { let msg = format!("{err:#}"); if msg.contains("Project not found.") { println!("Project not found yet; it will be created on first set."); Ok(HashMap::new()) } else { println!("Unable to read existing env vars: {err}"); println!("Run `f env login` to authenticate with cloud."); Err(err) } } } } fn default_stripe_mode_for_env(env_name: &str) -> StripeModeArg { if env_name.eq_ignore_ascii_case("production") { StripeModeArg::Live } else { StripeModeArg::Test } } fn prompt_stripe_mode(default: StripeModeArg) -> Result<StripeModeArg> { let default_label = match default { StripeModeArg::Test => "test", StripeModeArg::Live => "live", }; let value = prompt_line("Stripe mode (test/live)", Some(default_label))?; match value.trim().to_ascii_lowercase().as_str() { "" => Ok(default), "test" | "t" => Ok(StripeModeArg::Test), "live" | "l" => Ok(StripeModeArg::Live), other => { println!("Unknown mode '{other}', using {default_label}."); Ok(default) } } } fn collect_stripe_keys(flow_cfg: &config::Config) -> Vec<String> { let mut keys = Vec::new(); if let Some(cf) = flow_cfg.cloudflare.as_ref() { for key in cf.env_keys.iter().chain(cf.env_vars.iter()) { if is_stripe_key(key) && !keys.contains(key) { keys.push(key.clone()); } } } if keys.is_empty() { keys = vec![ "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_PRO_PRICE_ID", "STRIPE_REFILL_PRICE_ID", "VITE_STRIPE_PUBLISHABLE_KEY", ] .into_iter() .map(|key| key.to_string()) .collect(); } keys } fn is_stripe_key(key: &str) -> bool { key.starts_with("STRIPE_") || key.starts_with("VITE_STRIPE_") } struct StripeKeySpec { key: String, required: bool, secret: bool, description: &'static str, test_steps: &'static [&'static str], live_steps: &'static [&'static str], expected_test_prefix: Option<&'static str>, expected_live_prefix: Option<&'static str>, } impl StripeKeySpec { fn instructions(&self, mode: StripeModeArg) -> &'static [&'static str] { match mode { StripeModeArg::Test => self.test_steps, StripeModeArg::Live => self.live_steps, } } fn expected_prefix(&self, mode: StripeModeArg) -> Option<&'static str> { match mode { StripeModeArg::Test => self.expected_test_prefix, StripeModeArg::Live => self.expected_live_prefix, } } } fn stripe_key_spec(key: &str) -> StripeKeySpec { match key { "STRIPE_SECRET_KEY" => StripeKeySpec { key: key.to_string(), required: true, secret: true, description: "Server secret key for Stripe API access.", test_steps: &[ "Stripe Dashboard (test mode) -> Developers -> API keys.", "Copy the Secret key (starts with sk_test_).", ], live_steps: &[ "Stripe Dashboard (live mode) -> Developers -> API keys.", "Copy the Secret key (starts with sk_live_).", ], expected_test_prefix: Some("sk_test_"), expected_live_prefix: Some("sk_live_"), }, "VITE_STRIPE_PUBLISHABLE_KEY" => StripeKeySpec { key: key.to_string(), required: true, secret: false, description: "Client publishable key for Stripe.js.", test_steps: &[ "Stripe Dashboard (test mode) -> Developers -> API keys.", "Copy the Publishable key (starts with pk_test_).", ], live_steps: &[ "Stripe Dashboard (live mode) -> Developers -> API keys.", "Copy the Publishable key (starts with pk_live_).", ], expected_test_prefix: Some("pk_test_"), expected_live_prefix: Some("pk_live_"), }, "STRIPE_WEBHOOK_SECRET" => StripeKeySpec { key: key.to_string(), required: true, secret: true, description: "Webhook signing secret for Stripe events.", test_steps: &[ "Local dev: run `stripe listen --print-secret` to get a whsec_... value.", "Or Stripe Dashboard (test mode) -> Developers -> Webhooks -> Add endpoint.", ], live_steps: &[ "Stripe Dashboard (live mode) -> Developers -> Webhooks -> Add endpoint.", "Copy the Signing secret (starts with whsec_).", ], expected_test_prefix: Some("whsec_"), expected_live_prefix: Some("whsec_"), }, "STRIPE_PRO_PRICE_ID" => StripeKeySpec { key: key.to_string(), required: true, secret: false, description: "Price ID for your main subscription plan.", test_steps: &[ "Stripe Dashboard (test mode) -> Products -> select your plan.", "Copy the Price ID (starts with price_).", ], live_steps: &[ "Stripe Dashboard (live mode) -> Products -> select your plan.", "Copy the Price ID (starts with price_).", ], expected_test_prefix: Some("price_"), expected_live_prefix: Some("price_"), }, "STRIPE_REFILL_PRICE_ID" => StripeKeySpec { key: key.to_string(), required: false, secret: false, description: "Optional price ID for top-up/refill credits.", test_steps: &[ "Stripe Dashboard (test mode) -> Products -> create a refill product.", "Copy the Price ID (starts with price_).", ], live_steps: &[ "Stripe Dashboard (live mode) -> Products -> create a refill product.", "Copy the Price ID (starts with price_).", ], expected_test_prefix: Some("price_"), expected_live_prefix: Some("price_"), }, _ => { let is_secret = key.contains("SECRET") || key.contains("WEBHOOK"); StripeKeySpec { key: key.to_string(), required: false, secret: is_secret, description: "Stripe-related configuration value.", test_steps: &["Stripe Dashboard (test mode) -> copy the requested value."], live_steps: &["Stripe Dashboard (live mode) -> copy the requested value."], expected_test_prefix: None, expected_live_prefix: None, } } } } fn resolve_project_root(path: Option<&PathBuf>) -> Result<(PathBuf, PathBuf, config::Config)> { let start = match path { Some(path) => path.clone(), None => std::env::current_dir().context("failed to read current directory")?, }; let flow_path = if start.is_file() && start.file_name().and_then(|name| name.to_str()) == Some("flow.toml") { start.clone() } else { find_flow_toml(&start) .ok_or_else(|| anyhow::anyhow!("flow.toml not found. Run from a Flow project."))? }; let project_root = flow_path .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| start.clone()); let flow_cfg = config::load(&flow_path)?; Ok((project_root, flow_path, flow_cfg)) } fn find_flow_toml(start: &PathBuf) -> Option<PathBuf> { let mut current = start.clone(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } struct DirGuard { previous: PathBuf, } impl DirGuard { fn new(path: &Path) -> Result<Self> { let previous = std::env::current_dir().context("failed to read current directory")?; std::env::set_current_dir(path) .with_context(|| format!("failed to switch to {}", path.display()))?; Ok(Self { previous }) } } impl Drop for DirGuard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.previous); } } fn prompt_line(message: &str, default: Option<&str>) -> Result<String> { if let Some(default) = default { print!("{message} [{default}]: "); } else { print!("{message}: "); } io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(default.unwrap_or("").to_string()); } Ok(trimmed.to_string()) } fn prompt_secret(message: &str) -> Result<String> { print!("{message}: "); io::stdout().flush()?; let input = rpassword::read_password().context("failed to read secret input")?; Ok(input.trim().to_string()) } fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> { let prompt = if default_yes { "[Y/n]" } else { "[y/N]" }; print!("{message} {prompt}: "); io::stdout().flush()?; if io::stdin().is_terminal() { return read_yes_no_key(default_yes); } let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_yes); } Ok(answer == "y" || answer == "yes") } fn read_yes_no_key(default_yes: bool) -> Result<bool> { enable_raw_mode().context("failed to enable raw mode")?; let mut selection = default_yes; let mut echo_char: Option<char> = None; loop { if let CEvent::Key(key) = event::read()? { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { selection = true; echo_char = Some('y'); break; } KeyCode::Char('n') | KeyCode::Char('N') => { selection = false; echo_char = Some('n'); break; } KeyCode::Enter => { break; } KeyCode::Esc => { selection = false; break; } _ => {} } } } disable_raw_mode().context("failed to disable raw mode")?; if let Some(ch) = echo_char { println!("{ch}"); } else { println!(); } Ok(selection) } ================================================ FILE: src/setup.rs ================================================ use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use crossterm::event::{self, Event as CEvent, KeyCode}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use ignore::WalkBuilder; use serde::{Deserialize, Serialize}; use crate::{ agents, cli::{SetupOpts, SetupTarget, TaskRunOpts}, config, deploy, docs, skills, start, tasks::{self, load_project_config}, }; pub fn run(opts: SetupOpts) -> Result<()> { let (project_root, config_path) = resolve_project_root(&opts.config)?; let mut created_flow_toml = false; let mut upgraded_flow_toml = false; match opts.target { Some(SetupTarget::Docs) => { return docs::create_docs_scaffold_at(&project_root, false); } Some(SetupTarget::Deploy) => { return setup_deploy(&project_root, &config_path); } Some(SetupTarget::Release) => { return setup_release(&project_root, &config_path); } None => {} } if maybe_run_existing_setup_task(&config_path)? { return Ok(()); } if !start::is_bootstrapped(&project_root) || !config_path.exists() { start::run_at(&project_root)?; } if !config_path.exists() { create_flow_toml_auto(&project_root, &config_path)?; created_flow_toml = true; } if !created_flow_toml { match maybe_upgrade_existing_flow_toml(&project_root, &config_path) { Ok(true) => { upgraded_flow_toml = true; println!("Updated flow.toml with Codex-first baseline sections."); } Ok(false) => {} Err(err) => { eprintln!("⚠ failed to update flow.toml baseline: {err}"); } } } let (config_path, cfg) = load_project_config(config_path)?; // Ensure Codex/Claude skills are present before running any setup task. // This is the main entrypoint users expect to "load project skills". let skills_summary = skills::ensure_project_skills_at(&project_root, &cfg)?; if !skills_summary.is_noop() { if skills_summary.task_skills_created > 0 || skills_summary.task_skills_updated > 0 { println!( "✓ Synced flow.toml tasks to .ai/skills (created {}, updated {})", skills_summary.task_skills_created, skills_summary.task_skills_updated ); } if !skills_summary.installed_skills.is_empty() { println!( "✓ Installed skills: {}", skills_summary.installed_skills.join(", ") ); } } if upgraded_flow_toml { skills::maybe_reload_codex_skills( &project_root, cfg.skills.as_ref(), "setup baseline upgrade", ); } ensure_bike_gitignore(&project_root)?; ensure_project_dependencies(&cfg)?; ensure_pnpm_only_built_deps(&project_root)?; if tasks::find_task(&cfg, "setup").is_some() { if created_flow_toml { println!("Running setup task..."); } let config_path_for_task = config_path.clone(); let result = tasks::run(TaskRunOpts { config: config_path_for_task, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: "setup".to_string(), args: Vec::new(), }); if let Err(err) = refresh_skills_after_setup_task(&project_root, &config_path) { eprintln!("⚠ failed to refresh project skills after setup task: {err}"); } if result.is_ok() { if let Err(err) = write_setup_checkpoint(&project_root, &config_path) { eprintln!("⚠ failed to write setup checkpoint: {err}"); } } return result; } if cfg.aliases.is_empty() { println!( "# No setup task or aliases defined in {}.", config_path.display() ); println!("# Add a setup task or an alias table like:"); println!("# [[alias]]"); println!("# fr = \"f run\""); if let Err(err) = write_setup_checkpoint(&project_root, &config_path) { eprintln!("⚠ failed to write setup checkpoint: {err}"); } return Ok(()); } println!("# flow aliases from {}", config_path.display()); println!( "# Apply them in your shell with: eval \"$(f setup --config {})\"", config_path.display() ); for line in format_alias_lines(&cfg.aliases) { println!("{line}"); } if let Err(err) = write_setup_checkpoint(&project_root, &config_path) { eprintln!("⚠ failed to write setup checkpoint: {err}"); } Ok(()) } fn maybe_run_existing_setup_task(config_path: &Path) -> Result<bool> { if !config_path.exists() { return Ok(false); } let (config_path, cfg) = load_project_config(config_path.to_path_buf())?; if tasks::find_task(&cfg, "setup").is_none() { return Ok(false); } tasks::run(TaskRunOpts { config: config_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: "setup".to_string(), args: Vec::new(), })?; Ok(true) } fn refresh_skills_after_setup_task(project_root: &Path, config_path: &Path) -> Result<()> { let (_, cfg) = load_project_config(config_path.to_path_buf())?; let summary = skills::ensure_project_skills_at(project_root, &cfg)?; if !summary.is_noop() { if summary.task_skills_created > 0 || summary.task_skills_updated > 0 { println!( "✓ Refreshed flow.toml tasks to .ai/skills after setup (created {}, updated {})", summary.task_skills_created, summary.task_skills_updated ); } if !summary.installed_skills.is_empty() { println!( "✓ Installed skills after setup: {}", summary.installed_skills.join(", ") ); } skills::maybe_reload_codex_skills( project_root, cfg.skills.as_ref(), "setup post-task skill sync", ); } Ok(()) } #[derive(Serialize)] struct SetupCheckpoint { version: u32, commit: String, timestamp_ms: u64, config_path: String, source: String, } fn current_git_commit(project_root: &Path) -> Option<String> { let output = Command::new("git") .arg("-C") .arg(project_root) .arg("rev-parse") .arg("HEAD") .stdout(Stdio::piped()) .stderr(Stdio::null()) .output() .ok()?; if !output.status.success() { return None; } let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); if commit.is_empty() { None } else { Some(commit) } } fn write_setup_checkpoint(project_root: &Path, config_path: &Path) -> Result<()> { let rise_dir = project_root.join(".rise"); fs::create_dir_all(&rise_dir)?; let timestamp_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let checkpoint = SetupCheckpoint { version: 1, commit: current_git_commit(project_root).unwrap_or_default(), timestamp_ms, config_path: config_path.display().to_string(), source: "flow".to_string(), }; let payload = serde_json::to_string_pretty(&checkpoint)?; fs::write(rise_dir.join("setup.json"), payload)?; Ok(()) } fn ensure_bike_gitignore(project_root: &Path) -> Result<()> { add_gitignore_entry(project_root, ".ai/todos/*.bike")?; add_gitignore_entry(project_root, ".ai/review-log.jsonl") } fn ensure_project_dependencies(cfg: &config::Config) -> Result<()> { if cfg.dependencies.is_empty() { return Ok(()); } let mut commands = Vec::new(); for spec in cfg.dependencies.values() { spec.extend_commands(&mut commands); } let mut missing = std::collections::BTreeSet::new(); for command in commands { if which::which(&command).is_err() { missing.insert(command); } } if missing.is_empty() { return Ok(()); } println!( "Missing dependencies: {}", missing.iter().cloned().collect::<Vec<_>>().join(", ") ); if !brew_available() { println!("Homebrew not found. Install missing deps manually."); return Ok(()); } let mut packages = std::collections::BTreeSet::new(); for command in &missing { if let Some(pkg) = brew_package_for_command(command) { packages.insert(pkg); } else { println!( " - No brew mapping for '{}'; install it manually.", command ); } } if packages.is_empty() { return Ok(()); } println!( "Installing missing deps with Homebrew: {}", packages.iter().cloned().collect::<Vec<_>>().join(", ") ); for pkg in packages { let status = Command::new("brew") .args(["install", &pkg]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .with_context(|| format!("failed to run brew install {}", pkg))?; if !status.success() { println!(" - brew install {} failed; install it manually.", pkg); } } Ok(()) } fn brew_available() -> bool { Command::new("brew") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } fn brew_package_for_command(command: &str) -> Option<String> { match command { "pnpm" => Some("pnpm".to_string()), "yarn" => Some("yarn".to_string()), "bun" => Some("bun".to_string()), "node" | "npm" => Some("node".to_string()), "python" | "python3" => Some("python".to_string()), "go" => Some("go".to_string()), "rustc" | "cargo" => Some("rust".to_string()), "wasm-pack" => Some("wasm-pack".to_string()), _ => None, } } fn ensure_pnpm_only_built_deps(project_root: &Path) -> Result<()> { let workspace_path = project_root.join("pnpm-workspace.yaml"); if !workspace_path.exists() { return Ok(()); } let mut content = fs::read_to_string(&workspace_path) .with_context(|| format!("failed to read {}", workspace_path.display()))?; let mut needed = std::collections::BTreeSet::new(); if repo_contains_package(project_root, "electron") { needed.insert("electron".to_string()); } if repo_contains_package(project_root, "@swc/core") { needed.insert("@swc/core".to_string()); } if needed.is_empty() { return Ok(()); } let has_only_built = content.contains("onlyBuiltDependencies:"); let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect(); let mut start_idx = None; for (idx, line) in lines.iter().enumerate() { if line.trim() == "onlyBuiltDependencies:" { start_idx = Some(idx); break; } } if start_idx.is_none() { lines.push("".to_string()); lines.push("onlyBuiltDependencies:".to_string()); start_idx = Some(lines.len() - 1); } let start_idx = start_idx.unwrap(); let mut existing = std::collections::BTreeSet::new(); let mut insert_at = start_idx + 1; for idx in start_idx + 1..lines.len() { let line = lines[idx].trim(); if !line.starts_with("- ") { insert_at = idx; break; } existing.insert(line.trim_start_matches("- ").trim().to_string()); insert_at = idx + 1; } let missing: Vec<String> = needed .into_iter() .filter(|dep| !existing.contains(dep)) .collect(); if missing.is_empty() { return Ok(()); } for (offset, dep) in missing.iter().enumerate() { lines.insert(insert_at + offset, format!(" - {}", dep)); } content = lines.join("\n"); if !content.ends_with('\n') { content.push('\n'); } fs::write(&workspace_path, content) .with_context(|| format!("failed to update {}", workspace_path.display()))?; if has_only_built { println!( "Updated pnpm-workspace.yaml onlyBuiltDependencies with: {}", missing.join(", ") ); } else { println!( "Added pnpm-workspace.yaml onlyBuiltDependencies with: {}", missing.join(", ") ); } Ok(()) } fn repo_contains_package(project_root: &Path, needle: &str) -> bool { let walker = WalkBuilder::new(project_root) .hidden(false) .ignore(true) .git_ignore(true) .git_exclude(true) .build(); for entry in walker.flatten() { if entry.path().file_name().and_then(|n| n.to_str()) != Some("package.json") { continue; } if let Ok(text) = fs::read_to_string(entry.path()) { if text.contains(&format!("\"{}\"", needle)) { return true; } } } false } fn resolve_project_root(config_path: &PathBuf) -> Result<(PathBuf, PathBuf)> { let cwd = std::env::current_dir().context("failed to get current directory")?; let resolved = if config_path.is_absolute() { config_path.clone() } else { cwd.join(config_path) }; let root = resolved.parent().map(|p| p.to_path_buf()).unwrap_or(cwd); Ok((root, resolved)) } fn setup_deploy(project_root: &Path, config_path: &Path) -> Result<()> { let server_reason = detect_server_project(project_root); let auto_mode = server_reason.is_some(); if !config_path.exists() { if auto_mode { create_flow_toml_auto(project_root, config_path)?; } else { create_flow_toml_interactive(project_root, config_path)?; } } let mut flow_content = fs::read_to_string(config_path).unwrap_or_default(); if has_host_section(&flow_content) { if auto_mode { repair_existing_host_config(project_root, config_path, &flow_content)?; } else { println!("flow.toml already includes [host] configuration."); } return Ok(()); } let is_tty = io::stdin().is_terminal(); let mut defaults = deploy_defaults(project_root); if let Some(reason) = server_reason.as_deref() { println!("Detected server project: {reason}"); if !auto_mode && is_tty && !prompt_yes_no("Configure Linux host deployment now?", true)? { println!("Skipped host setup. Run `f setup deploy` later to configure."); return Ok(()); } let _ = deploy::ensure_deploy_helper(); let template = load_server_setup_template(); if let Some(template) = template.as_ref() { println!("Using server setup template from {}.", template.source); } apply_server_template(&mut defaults, template.as_ref(), project_root); if !auto_mode && is_tty && prompt_yes_no("Use AI to draft host config?", true)? { println!("Generating host config with AI..."); io::stdout().flush()?; let result = generate_host_config_with_agent(project_root, None); match result { Ok(text) => { if let Some(host_cfg) = extract_host_config(&text) { if let Some(reason) = host_config_mismatch_reason(project_root, &host_cfg) { println!("Warning: {}", reason); println!("Using detected defaults instead."); } else { apply_host_overrides(&mut defaults, &host_cfg); } } else { println!("Warning: AI output did not include [host] config."); } } Err(err) => { println!("Warning: AI generation failed: {}", err); } } } } let (dest, run, service, setup_script, env_file, domain, ssl, port) = if server_reason.is_some() { ( defaults.dest.clone(), defaults.run.clone(), Some(defaults.service.clone()), normalize_optional(defaults.setup_path.clone()), defaults.env_file.clone(), defaults.domain.clone(), defaults.ssl && defaults.domain.is_some(), if defaults.domain.is_some() { defaults.port } else { None }, ) } else { let dest = if is_tty { prompt_line("Remote deploy path", Some(&defaults.dest))? } else { defaults.dest.clone() }; let run = if is_tty { let value = prompt_line("Run command", defaults.run.as_deref())?; normalize_optional(value) } else { defaults.run.clone() }; if run.is_none() { println!("Warning: no run command set; deploy will not create a systemd service."); } let service = if is_tty { let value = prompt_line("Systemd service name", Some(&defaults.service))?; normalize_optional(value) } else { Some(defaults.service.clone()) }; let setup_script = if is_tty { let value = prompt_line( "Setup script path (relative to repo)", Some(&defaults.setup_path), )?; normalize_optional(value) } else { Some(defaults.setup_path.clone()) }; let env_file = if is_tty { prompt_line_optional( "Env file to upload (copied to remote as .env)", defaults.env_file.as_deref(), )? } else { defaults.env_file.clone() }; let domain = if is_tty { prompt_line_optional("Domain (blank to skip)", defaults.domain.as_deref())? } else { defaults.domain.clone() }; let ssl = if is_tty && domain.is_some() { prompt_yes_no("Enable SSL via Let's Encrypt?", defaults.ssl)? } else { defaults.ssl && domain.is_some() }; let port = if domain.is_some() { if is_tty { prompt_u16_optional("Service port for nginx", defaults.port)? } else { defaults.port } } else { None }; ( dest, run, service, setup_script, env_file, domain, ssl, port, ) }; if server_reason.is_some() && run.is_none() { println!("Warning: no run command set; deploy will not create a systemd service."); } if let Some(script_path) = setup_script.as_ref() { if let Some(content) = defaults.setup_script_content.as_deref() { ensure_setup_script(project_root, script_path, content, false)?; } } if let Some(env_path) = env_file.as_ref() { ensure_env_file( project_root, env_path, defaults.env_example.as_ref(), !auto_mode && is_tty, auto_mode, )?; } if auto_mode { maybe_configure_deploy_host(true)?; } else if is_tty { maybe_configure_deploy_host(false)?; } let host_cfg = HostSetupConfig { dest, setup: setup_script, run, port, service, env_file, domain, ssl, }; let host_section = render_host_section(&host_cfg); flow_content = append_section(&flow_content, &host_section); fs::write(config_path, flow_content) .with_context(|| format!("failed to write {}", config_path.display()))?; println!("Added [host] config to flow.toml."); println!("Next: run `f deploy` to deploy."); Ok(()) } fn setup_release(project_root: &Path, config_path: &Path) -> Result<()> { if !config_path.exists() { create_flow_toml_interactive(project_root, config_path)?; } let mut flow_content = fs::read_to_string(config_path).unwrap_or_default(); if has_host_section(&flow_content) { println!("flow.toml already includes [host] configuration."); return Ok(()); } let Some(reason) = detect_server_project(project_root) else { println!("No server project detected. Add [host] manually or run `f setup deploy`."); return Ok(()); }; println!("Detected server project: {reason}"); if io::stdin().is_terminal() && !prompt_yes_no("Configure Linux host deployment now?", true)? { println!("Skipped host setup. Run `f setup deploy` or edit flow.toml later."); return Ok(()); } let template = load_server_setup_template(); if let Some(template) = template.as_ref() { println!("Using server setup template from {}.", template.source); } let mut defaults = deploy_defaults(project_root); apply_server_template(&mut defaults, template.as_ref(), project_root); if defaults.run.is_none() { println!("Warning: no run command set; deploy will not create a systemd service."); } if let Some(content) = defaults.setup_script_content.as_deref() { if !defaults.setup_path.trim().is_empty() { ensure_setup_script(project_root, &defaults.setup_path, content, false)?; } } if let Some(env_path) = defaults.env_file.as_ref() { ensure_env_file( project_root, env_path, defaults.env_example.as_ref(), false, false, )?; } if io::stdin().is_terminal() { maybe_configure_deploy_host(false)?; } let host_cfg = HostSetupConfig { dest: defaults.dest, setup: normalize_optional(defaults.setup_path), run: defaults.run, port: defaults.port, service: Some(defaults.service), env_file: defaults.env_file, domain: defaults.domain, ssl: defaults.ssl, }; let host_section = render_host_section(&host_cfg); flow_content = append_section(&flow_content, &host_section); fs::write(config_path, flow_content) .with_context(|| format!("failed to write {}", config_path.display()))?; println!("Added [host] config to flow.toml."); println!("Next: run `f deploy` to deploy."); Ok(()) } fn create_flow_toml_interactive(project_root: &Path, config_path: &Path) -> Result<()> { println!("No flow.toml found. Let's create one."); if !io::stdin().is_terminal() { let content = default_flow_template(project_root); write_flow_toml(config_path, &content)?; return Ok(()); } let use_ai = prompt_yes_no("Generate setup/dev tasks with AI?", true)?; let mut content: Option<String> = None; let mut streamed_ai_output = false; let mut used_ai_content = false; if use_ai { let hint_input = prompt_optional("Any notes about how dev should run? (optional)")?; let hint = if hint_input.trim().is_empty() { None } else { Some(hint_input.as_str()) }; println!("Generating flow.toml with AI..."); io::stdout().flush()?; let use_streaming = io::stdin().is_terminal(); let result = if use_streaming { generate_flow_toml_with_agent_streaming(project_root, hint) } else { generate_flow_toml_with_agent(project_root, hint) }; match result { Ok(text) => { if use_streaming { streamed_ai_output = true; } if let Some(toml) = extract_flow_toml(&text) { if let Some(reason) = ai_flow_toml_mismatch_reason(project_root, &toml) { println!("Warning: {}", reason); println!("Using detected defaults instead."); } else { content = Some(toml); used_ai_content = true; } } else { println!("Warning: AI output did not include flow.toml content."); } } Err(err) => { println!("Warning: AI generation failed: {}", err); } } } if content.is_none() { let defaults = suggested_commands(project_root); let setup_cmd = defaults.setup.unwrap_or_default(); let dev_cmd = defaults.dev.unwrap_or_default(); content = Some(render_flow_toml(&setup_cmd, &dev_cmd, defaults.deps)); println!("Using detected defaults. Edit flow.toml if needed."); } let mut content = ensure_trailing_newline(content.unwrap_or_else(|| default_flow_template(project_root))); let enable_bun_testing_gate = detect_bun_context(project_root, &content); content = ensure_codex_flow_baseline(&content, enable_bun_testing_gate); if !used_ai_content || !streamed_ai_output { println!("\nProposed flow.toml:\n"); println!("{}", content); } write_flow_toml(config_path, &content)?; println!("Created flow.toml"); Ok(()) } fn create_flow_toml_auto(project_root: &Path, config_path: &Path) -> Result<()> { println!("No flow.toml found. Creating with detected defaults.\n"); let mut content = ensure_trailing_newline(default_flow_template(project_root)); let enable_bun_testing_gate = detect_bun_context(project_root, &content); content = ensure_codex_flow_baseline(&content, enable_bun_testing_gate); println!("{}", content); write_flow_toml(config_path, &content)?; println!("Created flow.toml"); Ok(()) } fn maybe_upgrade_existing_flow_toml(project_root: &Path, config_path: &Path) -> Result<bool> { if !config_path.exists() { return Ok(false); } let current = fs::read_to_string(config_path) .with_context(|| format!("failed to read {}", config_path.display()))?; let current = ensure_trailing_newline(current); let enable_bun_testing_gate = detect_bun_context(project_root, ¤t); let updated = ensure_codex_flow_baseline(¤t, enable_bun_testing_gate); if updated == current { return Ok(false); } write_flow_toml(config_path, &updated)?; Ok(true) } fn repair_existing_host_config( project_root: &Path, config_path: &Path, flow_content: &str, ) -> Result<()> { let Some(reason) = detect_server_project(project_root) else { println!("flow.toml already includes [host] configuration."); return Ok(()); }; println!("Detected server project: {reason}"); let cfg = config::load(config_path)?; let Some(mut host_cfg) = cfg.host else { println!("flow.toml already includes [host] configuration."); return Ok(()); }; let mut defaults = deploy_defaults(project_root); let template = load_server_setup_template(); apply_server_template(&mut defaults, template.as_ref(), project_root); let mut changed = false; let mut force_setup_script = false; if host_cfg.dest.is_none() { host_cfg.dest = Some(defaults.dest.clone()); changed = true; } if host_cfg.run.is_none() { if let Some(run) = defaults.run.clone() { host_cfg.run = Some(run); changed = true; } } else if let Some(run) = host_cfg.run.as_deref() { if let Some(default_run) = defaults.run.clone() { if let Some(reason) = command_mismatch_reason(project_root, run) { println!("Warning: replacing run command: {reason}"); host_cfg.run = Some(default_run); changed = true; } } } if host_cfg.service.is_none() { host_cfg.service = Some(defaults.service.clone()); changed = true; } if host_cfg.setup.is_none() { if !defaults.setup_path.trim().is_empty() { host_cfg.setup = Some(defaults.setup_path.clone()); changed = true; } } else if let Some(setup) = host_cfg.setup.as_deref() { if let Some(reason) = setup_script_mismatch_reason(project_root, setup) { println!("Warning: replacing setup script: {reason}"); if !defaults.setup_path.trim().is_empty() { host_cfg.setup = Some(defaults.setup_path.clone()); changed = true; force_setup_script = true; } } } if host_cfg.env_file.is_none() { if let Some(env_file) = defaults.env_file.clone() { host_cfg.env_file = Some(env_file); changed = true; } } if let Some(setup_path) = host_cfg.setup.as_deref() { if let Some(content) = defaults.setup_script_content.as_deref() { ensure_setup_script(project_root, setup_path, content, force_setup_script)?; } } if let Some(env_path) = host_cfg.env_file.as_deref() { ensure_env_file( project_root, env_path, defaults.env_example.as_ref(), false, true, )?; } maybe_configure_deploy_host(true)?; if host_cfg.run.is_none() { println!("Warning: no run command set; deploy will not create a systemd service."); } if changed { let host_section = render_host_section(&HostSetupConfig { dest: host_cfg.dest.unwrap_or_else(|| defaults.dest.clone()), setup: host_cfg.setup, run: host_cfg.run, port: host_cfg.port, service: host_cfg.service, env_file: host_cfg.env_file, domain: host_cfg.domain, ssl: host_cfg.ssl, }); let updated = replace_host_section(flow_content, &host_section); fs::write(config_path, updated) .with_context(|| format!("failed to write {}", config_path.display()))?; println!("Updated [host] config in flow.toml."); } else { println!("Host config looks good."); } Ok(()) } struct DeployDefaults { dest: String, run: Option<String>, service: String, setup_path: String, setup_script_content: Option<String>, env_example: Option<PathBuf>, env_file: Option<String>, port: Option<u16>, domain: Option<String>, ssl: bool, } struct HostSetupConfig { dest: String, setup: Option<String>, run: Option<String>, port: Option<u16>, service: Option<String>, env_file: Option<String>, domain: Option<String>, ssl: bool, } struct ServerSetupTemplate { host: deploy::HostConfig, source: String, } fn deploy_defaults(project_root: &Path) -> DeployDefaults { let project_name = guess_project_name(project_root); let dest = format!("/opt/{}", project_name); let run = default_run_command(project_root, &project_name); let service = project_name.clone(); let setup_path = "deploy/setup.sh".to_string(); let setup_script_content = Some(default_setup_script(project_root)); let env_example = find_env_example(project_root, &project_name); let env_file = env_example .as_ref() .and_then(|path| strip_example_suffix(project_root, path)); let port = Some(8080); let domain = None; let ssl = false; DeployDefaults { dest, run, service, setup_path, setup_script_content, env_example, env_file, port, domain, ssl, } } fn load_server_setup_template() -> Option<ServerSetupTemplate> { let mut host_config: Option<deploy::HostConfig> = None; let mut source: Option<String> = None; let global_path = config::default_config_path(); if global_path.exists() { if let Ok(cfg) = config::load(&global_path) { if let Some(setup) = cfg.setup { if let Some(server) = setup.server { if let Some(template_path) = server.template { let path = config::expand_path(&template_path); if path.exists() { if let Ok(template_cfg) = config::load(&path) { if let Some(host) = template_cfg.host { host_config = Some(host); source = Some(path.display().to_string()); } } } } if let Some(host) = server.host { host_config = Some(match host_config { Some(existing) => merge_host_config(existing, host), None => host, }); source = Some(format!("{} (inline)", global_path.display())); } } } } } if host_config.is_none() { if let Ok(template) = std::env::var("FLOW_SETUP_TEMPLATE") { let template_path = config::expand_path(&template); if template_path.exists() { if let Ok(cfg) = config::load(&template_path) { if let Some(host) = cfg.host { host_config = Some(host); source = Some(template_path.display().to_string()); } } } } } host_config.map(|host| ServerSetupTemplate { host, source: source.unwrap_or_else(|| "unknown".to_string()), }) } fn merge_host_config(base: deploy::HostConfig, overlay: deploy::HostConfig) -> deploy::HostConfig { deploy::HostConfig { dest: overlay.dest.or(base.dest), setup: overlay.setup.or(base.setup), run: overlay.run.or(base.run), port: overlay.port.or(base.port), service: overlay.service.or(base.service), env_file: overlay.env_file.or(base.env_file), env_source: overlay.env_source.or(base.env_source), env_keys: if overlay.env_keys.is_empty() { base.env_keys } else { overlay.env_keys }, env_project: overlay.env_project || base.env_project, environment: overlay.environment.or(base.environment), service_token: overlay.service_token.or(base.service_token), domain: overlay.domain.or(base.domain), ssl: overlay.ssl || base.ssl, } } fn apply_host_overrides(defaults: &mut DeployDefaults, host: &deploy::HostConfig) { if let Some(dest) = host.dest.as_deref() { defaults.dest = dest.to_string(); } if let Some(run) = host.run.as_deref() { defaults.run = Some(run.to_string()); } if let Some(service) = host.service.as_deref() { defaults.service = service.to_string(); } if let Some(setup) = host.setup.as_deref() { if looks_like_inline_script(setup) { defaults.setup_script_content = Some(setup.to_string()); } else if !setup.trim().is_empty() { defaults.setup_path = setup.to_string(); defaults.setup_script_content = None; } } if let Some(env_file) = host.env_file.as_deref() { if !env_file.trim().is_empty() { defaults.env_file = Some(env_file.to_string()); } } if let Some(port) = host.port { defaults.port = Some(port); } if let Some(domain) = host.domain.as_deref() { if !domain.trim().is_empty() { defaults.domain = Some(domain.to_string()); } } if host.ssl { defaults.ssl = true; } } fn apply_server_template( defaults: &mut DeployDefaults, template: Option<&ServerSetupTemplate>, project_root: &Path, ) { let Some(template) = template else { return; }; let host = &template.host; if let Some(setup) = host.setup.as_ref() { if let Some(reason) = setup_script_mismatch_reason(project_root, setup) { println!("Warning: skipping template setup script: {reason}"); } else if looks_like_inline_script(setup) { defaults.setup_script_content = Some(setup.to_string()); } else { defaults.setup_path = setup.to_string(); defaults.setup_script_content = None; } } if defaults.dest.trim().is_empty() { if let Some(dest) = host.dest.as_deref() { defaults.dest = dest.to_string(); } } if defaults.run.is_none() { if let Some(run) = host.run.as_deref() { defaults.run = Some(run.to_string()); } } if defaults.service.trim().is_empty() { if let Some(service) = host.service.as_deref() { defaults.service = service.to_string(); } } if let Some(env_file) = host.env_file.as_ref() { if defaults.env_file.is_none() { defaults.env_file = Some(env_file.to_string()); } } if host.port.is_some() { defaults.port = host.port; } if let Some(domain) = host.domain.as_ref() { defaults.domain = Some(domain.to_string()); } if host.ssl { defaults.ssl = true; } } fn looks_like_inline_script(value: &str) -> bool { value.contains('\n') || value.trim_start().starts_with("#!") || value.contains("set -e") } fn render_host_section(cfg: &HostSetupConfig) -> String { let mut out = String::from("[host]\n"); out.push_str(&format!("dest = \"{}\"\n", toml_escape(&cfg.dest))); if let Some(setup) = &cfg.setup { out.push_str(&format!("setup = \"{}\"\n", toml_escape(setup))); } if let Some(run) = &cfg.run { out.push_str(&format!("run = \"{}\"\n", toml_escape(run))); } if let Some(port) = cfg.port { out.push_str(&format!("port = {port}\n")); } if let Some(service) = &cfg.service { out.push_str(&format!("service = \"{}\"\n", toml_escape(service))); } if let Some(env_file) = &cfg.env_file { out.push_str(&format!("env_file = \"{}\"\n", toml_escape(env_file))); } if let Some(domain) = &cfg.domain { out.push_str(&format!("domain = \"{}\"\n", toml_escape(domain))); } if cfg.ssl { out.push_str("ssl = true\n"); } out } fn has_host_section(content: &str) -> bool { content.lines().any(|line| line.trim() == "[host]") } fn append_section(content: &str, section: &str) -> String { let mut out = content.to_string(); if !out.ends_with('\n') { out.push('\n'); } if !out.ends_with("\n\n") { out.push('\n'); } out.push_str(section.trim_end()); out.push('\n'); out } fn replace_host_section(content: &str, section: &str) -> String { let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect(); let had_trailing_newline = content.ends_with('\n'); let section_lines: Vec<String> = section .trim_end() .lines() .map(|line| line.to_string()) .collect(); if let Some(start) = lines.iter().position(|line| line.trim() == "[host]") { let end = find_section_end(&lines, start + 1); let mut updated = Vec::new(); updated.extend_from_slice(&lines[..start]); updated.extend(section_lines); updated.extend_from_slice(&lines[end..]); lines = updated; } else { if !lines.is_empty() && !lines .last() .map(|line| line.trim().is_empty()) .unwrap_or(false) { lines.push(String::new()); } lines.extend(section_lines); } let mut out = lines.join("\n"); if had_trailing_newline { out.push('\n'); } out } fn find_section_end(lines: &[String], start: usize) -> usize { for (idx, line) in lines.iter().enumerate().skip(start) { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { return idx; } } lines.len() } fn ensure_setup_script( project_root: &Path, script_path: &str, content: &str, overwrite: bool, ) -> Result<()> { let path = project_root.join(script_path); if path.exists() && !overwrite { return Ok(()); } if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } fs::write(&path, ensure_trailing_newline(content.to_string())) .with_context(|| format!("failed to write {}", path.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&path)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&path, perms)?; } if overwrite && path.exists() { println!("Updated {}", path.display()); } else { println!("Created {}", path.display()); } Ok(()) } fn ensure_env_file( project_root: &Path, env_file: &str, env_example: Option<&PathBuf>, interactive: bool, auto_gitignore: bool, ) -> Result<()> { let env_path = project_root.join(env_file); if env_path.exists() { return Ok(()); } if let Some(example_path) = env_example { if example_path.exists() { let should_copy = if interactive { prompt_yes_no( &format!( "Copy {} to {}?", display_relative(project_root, example_path), env_file ), true, )? } else { true }; if should_copy { if let Some(parent) = env_path.parent() { fs::create_dir_all(parent)?; } fs::copy(example_path, &env_path).with_context(|| { format!( "failed to copy {} to {}", example_path.display(), env_path.display() ) })?; println!("Created {}", env_path.display()); } } } if env_path.exists() && interactive { if prompt_yes_no("Add env file to .gitignore?", true)? { add_gitignore_entry(project_root, env_file)?; } } if env_path.exists() && auto_gitignore && !interactive { add_gitignore_entry(project_root, env_file)?; } Ok(()) } pub(crate) fn add_gitignore_entry(project_root: &Path, entry: &str) -> Result<()> { let gitignore_path = project_root.join(".gitignore"); let mut content = if gitignore_path.exists() { fs::read_to_string(&gitignore_path)? } else { String::new() }; if content.lines().any(|line| line.trim() == entry) { return Ok(()); } if !content.is_empty() && !content.ends_with('\n') { content.push('\n'); } if !content.is_empty() && !content.ends_with("\n\n") { content.push('\n'); } content.push_str(entry); content.push('\n'); fs::write(&gitignore_path, content) .with_context(|| format!("failed to write {}", gitignore_path.display()))?; Ok(()) } fn maybe_configure_deploy_host(auto_mode: bool) -> Result<()> { let existing = deploy::load_deploy_config()?.host; if existing.is_some() && auto_mode { return Ok(()); } let default_conn = existing .as_ref() .map(|host| format!("{}@{}:{}", host.user, host.host, host.port)) .or_else(deploy::default_linux_connection_string); if auto_mode { if let Some(conn_str) = default_conn.as_deref() { let conn = deploy::HostConnection::parse(conn_str)?; let mut config = deploy::load_deploy_config()?; config.host = Some(conn); deploy::save_deploy_config(&config)?; println!("Configured deploy host: {}", conn_str); } else { println!("Host not configured. Run `f deploy config`."); } return Ok(()); } let should_configure = if existing.is_some() { prompt_yes_no("Configure deploy host now?", false)? } else { prompt_yes_no("Configure deploy host now?", true)? }; if !should_configure { if existing.is_none() { println!("Host not configured. Run `f deploy set-host user@host:port`."); } return Ok(()); } let prompt = "SSH host (user@host:port)"; let input = prompt_line(prompt, default_conn.as_deref())?; if input.trim().is_empty() { if existing.is_none() { println!("Host not configured. Run `f deploy set-host user@host:port`."); } return Ok(()); } let conn = deploy::HostConnection::parse(input.trim())?; let mut config = deploy::load_deploy_config()?; config.host = Some(conn); deploy::save_deploy_config(&config)?; println!("Configured deploy host."); Ok(()) } fn guess_project_name(project_root: &Path) -> String { if let Some(name) = cargo_package_name(project_root) { return name; } if let Some(name) = package_json_name(project_root) { return name; } project_root .file_name() .and_then(|s| s.to_str()) .unwrap_or("app") .to_string() } fn cargo_package_name(project_root: &Path) -> Option<String> { let path = project_root.join("Cargo.toml"); let content = fs::read_to_string(&path).ok()?; let value: toml::Value = toml::from_str(&content).ok()?; let name = value .get("package") .and_then(toml::Value::as_table) .and_then(|pkg| pkg.get("name")) .and_then(toml::Value::as_str)?; Some(name.to_string()) } fn package_json_name(project_root: &Path) -> Option<String> { let path = project_root.join("package.json"); let content = fs::read_to_string(&path).ok()?; let value: serde_json::Value = serde_json::from_str(&content).ok()?; let name = value.get("name")?.as_str()?; Some(strip_scope(name).to_string()) } fn strip_scope(name: &str) -> &str { name.rsplit('/').next().unwrap_or(name) } fn default_run_command(project_root: &Path, project_name: &str) -> Option<String> { if project_root.join("Cargo.toml").exists() { return Some(format!("./target/release/{}", project_name)); } None } fn default_setup_script(project_root: &Path) -> String { if project_root.join("Cargo.toml").exists() { return rust_deploy_setup_script(); } if project_root.join("package.json").exists() { return node_deploy_setup_script(); } generic_deploy_setup_script() } fn rust_deploy_setup_script() -> String { r#"#!/usr/bin/env bash set -euo pipefail if ! command -v cargo >/dev/null 2>&1; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y if [ -f "$HOME/.cargo/env" ]; then . "$HOME/.cargo/env" fi fi cargo build --release "# .to_string() } fn node_deploy_setup_script() -> String { r#"#!/usr/bin/env bash set -euo pipefail if [ -f pnpm-lock.yaml ]; then pnpm install elif [ -f yarn.lock ]; then yarn install elif [ -f bun.lockb ]; then bun install elif [ -f package-lock.json ]; then npm ci else npm install fi npm run build "# .to_string() } fn generic_deploy_setup_script() -> String { r#"#!/usr/bin/env bash set -euo pipefail echo "TODO: add remote setup steps" "# .to_string() } fn find_env_example(project_root: &Path, project_name: &str) -> Option<PathBuf> { let candidates = [ format!("deploy/{}.env.example", project_name), "deploy/.env.example".to_string(), ".env.example".to_string(), ]; for candidate in candidates { let path = project_root.join(&candidate); if path.exists() { return Some(path); } } None } fn strip_example_suffix(project_root: &Path, path: &Path) -> Option<String> { let rel = path.strip_prefix(project_root).ok()?; let rel_str = rel.to_string_lossy(); let trimmed = rel_str.strip_suffix(".example")?; Some(trimmed.to_string()) } fn display_relative(project_root: &Path, path: &Path) -> String { path.strip_prefix(project_root) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| path.to_string_lossy().to_string()) } fn write_flow_toml(path: &Path, content: &str) -> Result<()> { fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn generate_flow_toml_with_agent(project_root: &Path, hint: Option<&str>) -> Result<String> { let mut prompt = String::new(); prompt.push_str( "Read the project files and generate a minimal flow.toml with setup and dev tasks.\n\n", ); prompt.push_str("Requirements:\n"); prompt.push_str("- Detect the project type by looking at files (Cargo.toml, package.json, *.tex, *.py, go.mod, etc.)\n"); prompt.push_str("- Include only what is needed to make dev work reliably.\n"); prompt.push_str("- The dev task must depend on setup (dependencies = [\"setup\"]).\n"); prompt.push_str("- Add descriptions and shortcuts for setup (s) and dev (d).\n"); prompt.push_str("- Use [deps] for required binaries.\n"); prompt.push_str("- If a task prompts for input, set interactive = true.\n"); prompt.push_str("- Include Codex baseline sections: [skills], [skills.codex], [commit.skill_gate], and [commit.skill_gate.min_version].\n"); prompt.push_str( "- Output ONLY the flow.toml content in a ```toml code block, no other commentary.\n\n", ); prompt.push_str("# flow.toml examples by project type:\n\n"); prompt.push_str("## Rust project (Cargo.toml exists):\n"); prompt.push_str("[deps]\n"); prompt.push_str("cargo = \"cargo\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"cargo build --locked\"\n"); prompt.push_str("dependencies = [\"cargo\"]\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"cargo run\"\n"); prompt.push_str("dependencies = [\"setup\"]\n\n"); prompt.push_str("## Node.js project (package.json exists):\n"); prompt.push_str("[deps]\n"); prompt.push_str("node = [\"node\", \"npm\"] # or pnpm, yarn, bun based on lock file\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"npm install\" # or pnpm install, yarn, bun install\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"npm run dev\"\n"); prompt.push_str("dependencies = [\"setup\"]\n\n"); prompt.push_str("## LaTeX project (.tex files exist):\n"); prompt.push_str("[deps]\n"); prompt.push_str("pdflatex = \"pdflatex\" # or latexmk if .latexmkrc exists\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"echo 'LaTeX project ready'\"\n"); prompt.push_str("dependencies = [\"pdflatex\"]\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"pdflatex main.tex\" # use detected main .tex file\n"); prompt.push_str("description = \"Compile document\"\n"); prompt.push_str("dependencies = [\"setup\"]\n\n"); prompt.push_str( "## Python project (pyproject.toml, setup.py, requirements.txt, or .py files):\n", ); prompt.push_str("[deps]\n"); prompt.push_str("python = \"python3\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"pip install -e .\" # or pip install -r requirements.txt\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str( "command = \"python main.py\" # use entry point from pyproject.toml or main .py file\n\n", ); prompt.push_str("## Go project (go.mod exists):\n"); prompt.push_str("[deps]\n"); prompt.push_str("go = \"go\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"go mod download\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"go run .\"\n\n"); if let Some(guidance) = project_guidance(project_root) { prompt.push_str("Guidance:\n"); prompt.push_str(&guidance); prompt.push('\n'); } let hints = project_hints(project_root); if !hints.is_empty() { prompt.push_str("Detected project hints:\n"); for hint in hints { prompt.push_str("- "); prompt.push_str(&hint); prompt.push('\n'); } prompt.push('\n'); } if let Some(hint) = hint { if !hint.trim().is_empty() { prompt.push_str("User notes:\n"); prompt.push_str(hint.trim()); prompt.push('\n'); } } agents::run_flow_agent_capture(&prompt) } fn generate_flow_toml_with_agent_streaming( project_root: &Path, hint: Option<&str>, ) -> Result<String> { let mut prompt = String::new(); prompt.push_str( "Read the project files and generate a minimal flow.toml with setup and dev tasks.\n\n", ); prompt.push_str("Requirements:\n"); prompt.push_str("- Detect the project type by looking at files (Cargo.toml, package.json, *.tex, *.py, go.mod, etc.)\n"); prompt.push_str("- Include only what is needed to make dev work reliably.\n"); prompt.push_str("- The dev task must depend on setup (dependencies = [\"setup\"]).\n"); prompt.push_str("- Add descriptions and shortcuts for setup (s) and dev (d).\n"); prompt.push_str("- Use [deps] for required binaries.\n"); prompt.push_str("- If a task prompts for input, set interactive = true.\n"); prompt.push_str("- Include Codex baseline sections: [skills], [skills.codex], [commit.skill_gate], and [commit.skill_gate.min_version].\n"); prompt.push_str( "- Output ONLY the flow.toml content in a ```toml code block, no other commentary.\n\n", ); prompt.push_str("# flow.toml examples by project type:\n\n"); prompt.push_str("## Rust project (Cargo.toml exists):\n"); prompt.push_str("[deps]\n"); prompt.push_str("cargo = \"cargo\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"cargo build --locked\"\n"); prompt.push_str("dependencies = [\"cargo\"]\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"cargo run\"\n"); prompt.push_str("dependencies = [\"setup\"]\n\n"); prompt.push_str("## Node.js project (package.json exists):\n"); prompt.push_str("[deps]\n"); prompt.push_str("node = [\"node\", \"npm\"] # or pnpm, yarn, bun based on lock file\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"npm install\" # or pnpm install, yarn, bun install\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"npm run dev\"\n"); prompt.push_str("dependencies = [\"setup\"]\n\n"); prompt.push_str("## LaTeX project (.tex files exist):\n"); prompt.push_str("[deps]\n"); prompt.push_str("pdflatex = \"pdflatex\" # or latexmk if .latexmkrc exists\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"echo 'LaTeX project ready'\"\n"); prompt.push_str("dependencies = [\"pdflatex\"]\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"pdflatex main.tex\" # use detected main .tex file\n"); prompt.push_str("description = \"Compile document\"\n"); prompt.push_str("dependencies = [\"setup\"]\n\n"); prompt.push_str( "## Python project (pyproject.toml, setup.py, requirements.txt, or .py files):\n", ); prompt.push_str("[deps]\n"); prompt.push_str("python = \"python3\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"pip install -e .\" # or pip install -r requirements.txt\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str( "command = \"python main.py\" # use entry point from pyproject.toml or main .py file\n\n", ); prompt.push_str("## Go project (go.mod exists):\n"); prompt.push_str("[deps]\n"); prompt.push_str("go = \"go\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"setup\"\n"); prompt.push_str("command = \"go mod download\"\n\n"); prompt.push_str("[[tasks]]\n"); prompt.push_str("name = \"dev\"\n"); prompt.push_str("command = \"go run .\"\n\n"); if let Some(guidance) = project_guidance(project_root) { prompt.push_str("Guidance:\n"); prompt.push_str(&guidance); prompt.push('\n'); } let hints = project_hints(project_root); if !hints.is_empty() { prompt.push_str("Detected project hints:\n"); for hint in hints { prompt.push_str("- "); prompt.push_str(&hint); prompt.push('\n'); } prompt.push('\n'); } if let Some(hint) = hint { if !hint.trim().is_empty() { prompt.push_str("User notes:\n"); prompt.push_str(hint.trim()); prompt.push('\n'); } } agents::run_flow_agent_capture_streaming(&prompt) } fn extract_flow_toml(raw: &str) -> Option<String> { if let Some(block) = extract_fenced_block(raw, "toml") { return Some(block); } if let Some(block) = extract_fenced_block(raw, "") { return Some(block); } if raw.contains("[[tasks]]") { return Some(raw.trim().to_string()); } None } fn extract_fenced_block(raw: &str, tag: &str) -> Option<String> { let fence = if tag.is_empty() { "```".to_string() } else { format!("```{tag}") }; let start = raw.find(&fence)?; let after = &raw[start + fence.len()..]; let after = after.strip_prefix('\n').unwrap_or(after); let end = after.find("```")?; Some(after[..end].trim().to_string()) } #[derive(Deserialize)] struct HostWrapper { host: Option<deploy::HostConfig>, } fn generate_host_config_with_agent(project_root: &Path, hint: Option<&str>) -> Result<String> { let defaults = deploy_defaults(project_root); let mut prompt = String::new(); prompt.push_str("Read the project and generate a minimal [host] config for flow.toml.\n"); prompt.push_str("Requirements:\n"); prompt.push_str("- Output ONLY TOML with a [host] section.\n"); prompt.push_str("- No explanations, no narration, no markdown fences.\n"); prompt.push_str("- Use relative paths for setup/env_file.\n"); prompt.push_str("- Use a production run command (avoid dev servers).\n"); prompt.push_str("- Keep it minimal; omit fields you cannot infer.\n\n"); prompt.push_str("Suggested defaults:\n"); prompt.push_str(&format!("- dest: {}\n", defaults.dest)); if let Some(run) = defaults.run.as_deref() { prompt.push_str(&format!("- run: {}\n", run)); } prompt.push_str(&format!("- service: {}\n", defaults.service)); if !defaults.setup_path.trim().is_empty() { prompt.push_str(&format!("- setup: {}\n", defaults.setup_path)); } if let Some(env_file) = defaults.env_file.as_deref() { prompt.push_str(&format!("- env_file: {}\n", env_file)); } if let Some(env_example) = defaults.env_example.as_ref() { prompt.push_str(&format!( "- env example: {}\n", display_relative(project_root, env_example) )); } if let Some(port) = defaults.port { prompt.push_str(&format!("- port: {}\n", port)); } prompt.push('\n'); if let Some(guidance) = project_guidance(project_root) { prompt.push_str("Guidance:\n"); prompt.push_str(&guidance); prompt.push('\n'); } let hints = project_hints(project_root); if !hints.is_empty() { prompt.push_str("Detected project hints:\n"); for hint in hints { prompt.push_str("- "); prompt.push_str(&hint); prompt.push('\n'); } prompt.push('\n'); } if let Some(hint) = hint { if !hint.trim().is_empty() { prompt.push_str("User notes:\n"); prompt.push_str(hint.trim()); prompt.push('\n'); } } agents::run_flow_agent_capture(&prompt) } fn extract_host_config(raw: &str) -> Option<deploy::HostConfig> { let content = extract_fenced_block(raw, "toml") .or_else(|| extract_fenced_block(raw, "")) .unwrap_or_else(|| raw.trim().to_string()); if content.trim().is_empty() { return None; } if content.contains("[host]") { if let Ok(wrapper) = toml::from_str::<HostWrapper>(&content) { if let Some(host) = wrapper.host { if host_has_values(&host) { return Some(host); } } } } else if let Ok(host) = toml::from_str::<deploy::HostConfig>(&content) { if host_has_values(&host) { return Some(host); } } None } fn host_has_values(host: &deploy::HostConfig) -> bool { host.dest.is_some() || host.setup.is_some() || host.run.is_some() || host.port.is_some() || host.service.is_some() || host.env_file.is_some() || host.domain.is_some() || host.ssl } fn host_config_mismatch_reason( project_root: &Path, host_cfg: &deploy::HostConfig, ) -> Option<String> { let has_cargo = project_root.join("Cargo.toml").exists(); let has_package = project_root.join("package.json").exists(); let mut uses_node = false; let mut uses_cargo = false; if let Some(run) = host_cfg.run.as_deref() { uses_node |= command_uses_node_tool(run); uses_cargo |= command_uses_cargo_tool(run); } if let Some(setup) = host_cfg.setup.as_deref() { if looks_like_inline_script(setup) { uses_node |= command_uses_node_tool(setup); uses_cargo |= command_uses_cargo_tool(setup); } else { let setup_path = project_root.join(setup); if setup_path.exists() { if let Ok(content) = fs::read_to_string(&setup_path) { uses_node |= command_uses_node_tool(&content); uses_cargo |= command_uses_cargo_tool(&content); } } } } if has_cargo && !has_package && uses_node { return Some( "AI suggested Node tooling (bun/npm/pnpm/yarn), but no package.json was found." .to_string(), ); } if has_package && !has_cargo && uses_cargo { return Some("AI suggested Cargo commands, but no Cargo.toml was found.".to_string()); } if let Some(reason) = host_config_name_mismatch(project_root, host_cfg) { return Some(reason); } None } fn command_mismatch_reason(project_root: &Path, command: &str) -> Option<String> { let has_cargo = project_root.join("Cargo.toml").exists(); let has_package = project_root.join("package.json").exists(); let uses_node = command_uses_node_tool(command); let uses_cargo = command_uses_cargo_tool(command); if has_cargo && !has_package && uses_node { return Some( "uses Node tooling but no package.json was found for this project.".to_string(), ); } if has_package && !has_cargo && uses_cargo { return Some("uses Cargo but no Cargo.toml was found for this project.".to_string()); } None } fn setup_script_mismatch_reason(project_root: &Path, setup: &str) -> Option<String> { let has_cargo = project_root.join("Cargo.toml").exists(); let has_package = project_root.join("package.json").exists(); let mut uses_node = false; let mut uses_cargo = false; if looks_like_inline_script(setup) { uses_node |= command_uses_node_tool(setup); uses_cargo |= command_uses_cargo_tool(setup); } else { let setup_path = project_root.join(setup); if setup_path.exists() { if let Ok(content) = fs::read_to_string(&setup_path) { uses_node |= command_uses_node_tool(&content); uses_cargo |= command_uses_cargo_tool(&content); } } } if has_cargo && !has_package && uses_node { return Some( "uses Node tooling but no package.json was found for this project.".to_string(), ); } if has_package && !has_cargo && uses_cargo { return Some("uses Cargo but no Cargo.toml was found for this project.".to_string()); } None } fn host_config_name_mismatch(project_root: &Path, host_cfg: &deploy::HostConfig) -> Option<String> { let expected_names = expected_project_names(project_root); if expected_names.is_empty() { return None; } let tokens = host_name_tokens(host_cfg); if tokens.is_empty() { return None; } let mut counts: HashMap<String, usize> = HashMap::new(); for token in tokens { if expected_names.contains(&token) { continue; } *counts.entry(token).or_insert(0) += 1; } let (token, count) = counts.into_iter().max_by_key(|(_, count)| *count)?; if count < 2 { return None; } let project_name = guess_project_name(project_root); Some(format!( "AI suggested host config for '{}', but the project looks like '{}'.", token, project_name )) } fn expected_project_names(project_root: &Path) -> HashSet<String> { let mut names = HashSet::new(); let guessed = guess_project_name(project_root); if !guessed.is_empty() { names.insert(guessed.to_ascii_lowercase()); } if let Some(name) = cargo_package_name(project_root) { names.insert(name.to_ascii_lowercase()); } if let Some(name) = package_json_name(project_root) { names.insert(name.to_ascii_lowercase()); } if let Some(folder) = project_root.file_name().and_then(|name| name.to_str()) { names.insert(folder.to_ascii_lowercase()); } for name in cargo_bin_names(project_root) { names.insert(name); } names } fn cargo_bin_names(project_root: &Path) -> Vec<String> { let path = project_root.join("Cargo.toml"); let content = match fs::read_to_string(&path) { Ok(content) => content, Err(_) => return Vec::new(), }; let value: toml::Value = match toml::from_str(&content) { Ok(value) => value, Err(_) => return Vec::new(), }; let mut names = Vec::new(); if let Some(bins) = value.get("bin").and_then(toml::Value::as_array) { for bin in bins { if let Some(name) = bin.get("name").and_then(toml::Value::as_str) { names.push(name.to_ascii_lowercase()); } } } names } fn host_name_tokens(host: &deploy::HostConfig) -> Vec<String> { let mut tokens = Vec::new(); if let Some(service) = host.service.as_deref() { if let Some(token) = normalize_host_token(service) { tokens.push(token); } } if let Some(dest) = host.dest.as_deref() { if let Some(seg) = Path::new(dest).file_name().and_then(|s| s.to_str()) { if let Some(token) = normalize_host_token(seg) { tokens.push(token); } } } if let Some(run) = host.run.as_deref() { if let Some(bin) = extract_run_binary(run) { if let Some(token) = normalize_host_token(&bin) { tokens.push(token); } } } if let Some(env_file) = host.env_file.as_deref() { if let Some(env_name) = extract_env_name(env_file) { if let Some(token) = normalize_host_token(&env_name) { tokens.push(token); } } } tokens } fn extract_run_binary(run: &str) -> Option<String> { let first = run.trim().split_whitespace().next()?; let trimmed = first.trim_matches(|c| c == '"' || c == '\''); let name = Path::new(trimmed) .file_name()? .to_string_lossy() .to_string(); if name.is_empty() { None } else { Some(name) } } fn extract_env_name(env_file: &str) -> Option<String> { let file_name = Path::new(env_file).file_name()?.to_string_lossy(); if file_name.starts_with('.') { return None; } let mut stem = Path::new(&*file_name) .file_stem() .map(|s| s.to_string_lossy().to_string())?; if let Some(stripped) = stem.strip_suffix(".env") { stem = stripped.to_string(); } if stem.is_empty() { None } else { Some(stem) } } fn normalize_host_token(token: &str) -> Option<String> { let trimmed = token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_'); if trimmed.len() < 2 { return None; } let lower = trimmed.to_ascii_lowercase(); if is_host_stop_token(&lower) { None } else { Some(lower) } } fn is_host_stop_token(token: &str) -> bool { matches!( token, "app" | "service" | "server" | "api" | "web" | "backend" | "frontend" | "bin" | "target" | "release" | "debug" | "dist" | "build" | "deploy" | "env" | "cargo" | "bun" | "npm" | "pnpm" | "yarn" | "node" ) } struct SuggestedCommands { setup: Option<String>, dev: Option<String>, deps: Vec<DepSpec>, } enum DepSpec { Single(&'static str, &'static str), Multiple(&'static str, &'static [&'static str]), } fn suggested_commands(project_root: &Path) -> SuggestedCommands { // Check root level first let cargo = project_root.join("Cargo.toml").exists(); if cargo { return SuggestedCommands { setup: Some("cargo build --locked".to_string()), dev: Some("cargo run".to_string()), deps: vec![DepSpec::Single("cargo", "cargo")], }; } let package_json = project_root.join("package.json").exists(); if package_json { return suggest_node_commands(project_root, None); } // Check for LaTeX project if let Some(cmds) = suggest_latex_commands(project_root, None) { return cmds; } // Check subdirectories for project files let subdir_projects = find_subdir_projects(project_root); if let Some(subdir) = subdir_projects.cargo { return SuggestedCommands { setup: Some(format!("cd {subdir} && cargo build --locked")), dev: Some(format!("cd {subdir} && cargo run")), deps: vec![DepSpec::Single("cargo", "cargo")], }; } if let Some(subdir) = subdir_projects.package { let subdir_path = project_root.join(&subdir); return suggest_node_commands(&subdir_path, Some(&subdir)); } if let Some(subdir) = subdir_projects.latex { let subdir_path = project_root.join(&subdir); if let Some(cmds) = suggest_latex_commands(&subdir_path, Some(&subdir)) { return cmds; } } SuggestedCommands { setup: None, dev: None, deps: Vec::new(), } } fn suggest_node_commands(project_path: &Path, subdir: Option<&str>) -> SuggestedCommands { let prefix = subdir.map(|s| format!("cd {s} && ")).unwrap_or_default(); // Check lock files first (most reliable indicator) if project_path.join("pnpm-lock.yaml").exists() { return SuggestedCommands { setup: Some(format!("{prefix}pnpm install")), dev: Some(format!("{prefix}pnpm dev")), deps: vec![DepSpec::Single("pnpm", "pnpm")], }; } if project_path.join("yarn.lock").exists() { return SuggestedCommands { setup: Some(format!("{prefix}yarn install")), dev: Some(format!("{prefix}yarn dev")), deps: vec![DepSpec::Single("yarn", "yarn")], }; } if project_path.join("bun.lockb").exists() { return SuggestedCommands { setup: Some(format!("{prefix}bun install")), dev: Some(format!("{prefix}bun dev")), deps: vec![DepSpec::Single("bun", "bun")], }; } if project_path.join("package-lock.json").exists() { return SuggestedCommands { setup: Some(format!("{prefix}npm ci")), dev: Some(format!("{prefix}npm run dev")), deps: vec![DepSpec::Multiple("node", &["node", "npm"])], }; } // No lock file - check package.json for hints if let Some(pm) = detect_package_manager_from_json(project_path) { return match pm.as_str() { "pnpm" => SuggestedCommands { setup: Some(format!("{prefix}pnpm install")), dev: Some(format!("{prefix}pnpm dev")), deps: vec![DepSpec::Single("pnpm", "pnpm")], }, "yarn" => SuggestedCommands { setup: Some(format!("{prefix}yarn install")), dev: Some(format!("{prefix}yarn dev")), deps: vec![DepSpec::Single("yarn", "yarn")], }, "bun" => SuggestedCommands { setup: Some(format!("{prefix}bun install")), dev: Some(format!("{prefix}bun dev")), deps: vec![DepSpec::Single("bun", "bun")], }, _ => SuggestedCommands { setup: Some(format!("{prefix}npm install")), dev: Some(format!("{prefix}npm run dev")), deps: vec![DepSpec::Multiple("node", &["node", "npm"])], }, }; } SuggestedCommands { setup: Some(format!("{prefix}npm install")), dev: Some(format!("{prefix}npm run dev")), deps: vec![DepSpec::Multiple("node", &["node", "npm"])], } } /// Detect LaTeX project and suggest build commands. /// Looks for .tex files and determines the main document file. fn suggest_latex_commands(project_path: &Path, subdir: Option<&str>) -> Option<SuggestedCommands> { let prefix = subdir.map(|s| format!("cd {s} && ")).unwrap_or_default(); // Find .tex files in the project let tex_files: Vec<_> = fs::read_dir(project_path) .ok()? .filter_map(|e| e.ok()) .filter(|e| e.path().extension().is_some_and(|ext| ext == "tex")) .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); if tex_files.is_empty() { return None; } // Determine the main LaTeX file let main_file = detect_main_tex_file(project_path, &tex_files); // Check for Makefile or latexmk config let has_makefile = project_path.join("Makefile").exists(); let has_latexmkrc = project_path.join(".latexmkrc").exists() || project_path.join("latexmkrc").exists(); if has_makefile { return Some(SuggestedCommands { setup: Some(format!("{prefix}echo 'LaTeX project ready'")), dev: Some(format!("{prefix}make")), deps: vec![ DepSpec::Single("pdflatex", "pdflatex"), DepSpec::Single("make", "make"), ], }); } if has_latexmkrc { return Some(SuggestedCommands { setup: Some(format!("{prefix}echo 'LaTeX project ready'")), dev: Some(format!("{prefix}latexmk")), deps: vec![DepSpec::Single("latexmk", "latexmk")], }); } // Default to pdflatex with detected main file Some(SuggestedCommands { setup: Some(format!("{prefix}echo 'LaTeX project ready'")), dev: Some(format!("{prefix}pdflatex {main_file}")), deps: vec![DepSpec::Single("pdflatex", "pdflatex")], }) } /// Detect the main .tex file in a LaTeX project. /// Priority: main.tex > document.tex > single .tex file > first alphabetically fn detect_main_tex_file(project_path: &Path, tex_files: &[String]) -> String { // Common main file names for name in [ "main.tex", "document.tex", "paper.tex", "thesis.tex", "cv.tex", "resume.tex", ] { if tex_files.contains(&name.to_string()) { return name.to_string(); } } // If only one .tex file, use it if tex_files.len() == 1 { return tex_files[0].clone(); } // Look for \documentclass in files to find the main document for file in tex_files { let path = project_path.join(file); if let Ok(content) = fs::read_to_string(&path) { // Main document has \documentclass, included files don't if content.contains("\\documentclass") { return file.clone(); } } } // Fallback to first file alphabetically let mut sorted = tex_files.to_vec(); sorted.sort(); sorted .first() .cloned() .unwrap_or_else(|| "main.tex".to_string()) } /// Detect package manager from package.json content. /// Checks: packageManager field, catalog: protocol usage, script commands. fn detect_package_manager_from_json(project_path: &Path) -> Option<String> { let path = project_path.join("package.json"); let content = fs::read_to_string(&path).ok()?; let value: serde_json::Value = serde_json::from_str(&content).ok()?; // 1. Check packageManager field (e.g., "pnpm@9.0.0", "yarn@4.0.0", "bun@1.0.0") if let Some(pm) = value.get("packageManager").and_then(|v| v.as_str()) { let pm_lower = pm.to_lowercase(); if pm_lower.starts_with("pnpm") { return Some("pnpm".to_string()); } if pm_lower.starts_with("yarn") { return Some("yarn".to_string()); } if pm_lower.starts_with("bun") { return Some("bun".to_string()); } if pm_lower.starts_with("npm") { return Some("npm".to_string()); } } // 2. Check for catalog: protocol in dependencies (pnpm workspace feature) let has_catalog = has_catalog_protocol(&value); if has_catalog { return Some("pnpm".to_string()); } // 3. Check scripts for package manager hints if let Some(scripts) = value.get("scripts").and_then(|v| v.as_object()) { let scripts_str = scripts .values() .filter_map(|v| v.as_str()) .collect::<Vec<_>>() .join(" "); // Check for explicit package manager usage in scripts if scripts_str.contains("pnpm ") || scripts_str.contains("pnpm run") { return Some("pnpm".to_string()); } if scripts_str.contains("bun run") || scripts_str.contains("bun ") { return Some("bun".to_string()); } if scripts_str.contains("yarn ") { return Some("yarn".to_string()); } } None } /// Check if package.json uses catalog: protocol in any dependency field. fn has_catalog_protocol(value: &serde_json::Value) -> bool { let dep_fields = [ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", ]; for field in dep_fields { if let Some(deps) = value.get(field).and_then(|v| v.as_object()) { for version in deps.values() { if let Some(v) = version.as_str() { if v.starts_with("catalog:") { return true; } } } } } // Also check workspaces.catalog (pnpm/yarn workspace catalog definition) if value .get("workspaces") .and_then(|v| v.get("catalog")) .is_some() { return true; } false } fn default_flow_template(project_root: &Path) -> String { let defaults = suggested_commands(project_root); let setup_cmd = defaults.setup.unwrap_or_default(); let dev_cmd = defaults.dev.unwrap_or_default(); render_flow_toml(&setup_cmd, &dev_cmd, defaults.deps) } fn project_hints(project_root: &Path) -> Vec<String> { let mut hints = Vec::new(); let candidates = [ "Cargo.toml", "package.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "package-lock.json", "pyproject.toml", "requirements.txt", "Makefile", "justfile", "Dockerfile", ]; for name in candidates { if project_root.join(name).exists() { hints.push(format!("{name}")); } } // Check for project files in immediate subdirectories if let Ok(entries) = fs::read_dir(project_root) { for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let subdir_name = match path.file_name().and_then(|n| n.to_str()) { Some(name) if !name.starts_with('.') => name, _ => continue, }; for name in ["Cargo.toml", "package.json"] { if path.join(name).exists() { hints.push(format!("{subdir_name}/{name}")); } } } } hints } fn project_guidance(project_root: &Path) -> Option<String> { let has_cargo = project_root.join("Cargo.toml").exists(); let has_package = project_root.join("package.json").exists(); let has_tex = has_tex_files(project_root); // Check for project files in subdirectories let subdir_projects = find_subdir_projects(project_root); let cargo_found = has_cargo || subdir_projects.cargo.is_some(); let package_found = has_package || subdir_projects.package.is_some(); let latex_found = has_tex || subdir_projects.latex.is_some(); // LaTeX-only projects if latex_found && !cargo_found && !package_found { if let Some(ref subdir) = subdir_projects.latex { return Some(format!( "Detected LaTeX project in {subdir}/. Use pdflatex/latexmk commands. Avoid bun/npm/pnpm/yarn/cargo." )); } return Some("Detected LaTeX project (.tex files). Use pdflatex or latexmk to compile; avoid bun/npm/pnpm/yarn/cargo.".to_string()); } match ( cargo_found, package_found, &subdir_projects.cargo, &subdir_projects.package, ) { (true, false, Some(subdir), _) => Some(format!( "Detected Rust project in {subdir}/. Run cargo commands from that directory (cd {subdir} && cargo build). Avoid bun/npm/pnpm/yarn." )), (true, false, None, _) => Some( "Detected Rust project (Cargo.toml). Use cargo commands; avoid bun/npm/pnpm/yarn." .to_string(), ), (false, true, _, Some(subdir)) => Some(format!( "Detected Node project in {subdir}/. Run npm/pnpm/yarn/bun commands from that directory. Avoid cargo." )), (false, true, _, None) => Some( "Detected Node project (package.json). Use npm/pnpm/yarn/bun commands; avoid cargo." .to_string(), ), (true, true, _, _) => { Some("Detected Rust + Node. Use the right tool for each step.".to_string()) } _ => None, } } /// Find project files (Cargo.toml, package.json, .tex files) in immediate subdirectories. struct SubdirProjects { cargo: Option<String>, package: Option<String>, latex: Option<String>, } fn find_subdir_projects(project_root: &Path) -> SubdirProjects { let mut cargo_subdir = None; let mut package_subdir = None; let mut latex_subdir = None; let entries = match fs::read_dir(project_root) { Ok(entries) => entries, Err(_) => { return SubdirProjects { cargo: None, package: None, latex: None, }; } }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let subdir_name = match path.file_name().and_then(|n| n.to_str()) { Some(name) if !name.starts_with('.') => name.to_string(), _ => continue, }; if cargo_subdir.is_none() && path.join("Cargo.toml").exists() { cargo_subdir = Some(subdir_name.clone()); } if package_subdir.is_none() && path.join("package.json").exists() { package_subdir = Some(subdir_name.clone()); } if latex_subdir.is_none() && has_tex_files(&path) { latex_subdir = Some(subdir_name); } if cargo_subdir.is_some() && package_subdir.is_some() && latex_subdir.is_some() { break; } } SubdirProjects { cargo: cargo_subdir, package: package_subdir, latex: latex_subdir, } } /// Check if a directory contains .tex files fn has_tex_files(path: &Path) -> bool { fs::read_dir(path) .map(|entries| { entries .flatten() .any(|e| e.path().extension().is_some_and(|ext| ext == "tex")) }) .unwrap_or(false) } fn detect_server_project(project_root: &Path) -> Option<String> { if let Some(reason) = detect_rust_server(project_root) { return Some(reason); } if let Some(reason) = detect_node_server(project_root) { return Some(reason); } None } fn detect_rust_server(project_root: &Path) -> Option<String> { let path = project_root.join("Cargo.toml"); let content = fs::read_to_string(&path).ok()?; let value: toml::Value = toml::from_str(&content).ok()?; let mut deps = std::collections::HashSet::new(); if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) { deps.extend(table.keys().cloned()); } if let Some(workspace) = value.get("workspace").and_then(toml::Value::as_table) { if let Some(table) = workspace .get("dependencies") .and_then(toml::Value::as_table) { deps.extend(table.keys().cloned()); } } let server_deps = [ "axum", "actix-web", "warp", "rocket", "hyper", "tower-http", "tonic", ]; for dep in server_deps { if deps.contains(dep) { return Some(format!("Rust server crate detected: {dep}")); } } None } fn detect_node_server(project_root: &Path) -> Option<String> { let path = project_root.join("package.json"); let content = fs::read_to_string(&path).ok()?; let value: serde_json::Value = serde_json::from_str(&content).ok()?; let mut deps = std::collections::HashSet::new(); for key in ["dependencies", "devDependencies", "peerDependencies"] { if let Some(table) = value.get(key).and_then(|v| v.as_object()) { deps.extend(table.keys().cloned()); } } let server_deps = [ "express", "fastify", "koa", "hono", "next", "remix", "nestjs", ]; for dep in server_deps { if deps.contains(dep) { return Some(format!("Node server framework detected: {dep}")); } } None } fn ai_flow_toml_mismatch_reason(project_root: &Path, toml_content: &str) -> Option<String> { let has_cargo = project_root.join("Cargo.toml").exists(); let has_package = project_root.join("package.json").exists(); let has_tex = has_tex_files(project_root); // Also check subdirectories let subdir_projects = find_subdir_projects(project_root); let cargo_found = has_cargo || subdir_projects.cargo.is_some(); let package_found = has_package || subdir_projects.package.is_some(); let latex_found = has_tex || subdir_projects.latex.is_some(); let parsed: toml::Value = toml::from_str(toml_content).ok()?; let tasks = parsed.get("tasks").and_then(toml::Value::as_array)?; let mut uses_node = false; let mut uses_cargo = false; let mut uses_latex = false; for task in tasks { let command = match task.get("command").and_then(toml::Value::as_str) { Some(cmd) => cmd, None => continue, }; uses_node |= command_uses_node_tool(command); uses_cargo |= command_uses_cargo_tool(command); uses_latex |= command_uses_latex_tool(command); } if cargo_found && !package_found && uses_node { return Some( "AI suggested Node tooling (bun/npm/pnpm/yarn), but no package.json was found." .to_string(), ); } if package_found && !cargo_found && uses_cargo { return Some("AI suggested Cargo commands, but no Cargo.toml was found.".to_string()); } if !latex_found && uses_latex { return Some("AI suggested LaTeX commands, but no .tex files were found.".to_string()); } None } fn command_uses_node_tool(command: &str) -> bool { ["bun", "npm", "pnpm", "yarn"] .iter() .any(|tool| command_mentions_tool(command, tool)) } fn command_uses_cargo_tool(command: &str) -> bool { command_mentions_tool(command, "cargo") } fn command_uses_latex_tool(command: &str) -> bool { [ "pdflatex", "xelatex", "lualatex", "latexmk", "latex", "bibtex", "biber", ] .iter() .any(|tool| command_mentions_tool(command, tool)) } fn command_mentions_tool(command: &str, tool: &str) -> bool { command.split_whitespace().any(|part| { let trimmed = part.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_'); trimmed.eq_ignore_ascii_case(tool) }) } fn render_flow_toml(setup_cmd: &str, dev_cmd: &str, deps: Vec<DepSpec>) -> String { let setup_cmd = setup_cmd.trim(); let dev_cmd = dev_cmd.trim(); let setup_cmd = if setup_cmd.is_empty() { "echo TODO: add setup command" } else { setup_cmd }; let dev_cmd = if dev_cmd.is_empty() { "echo TODO: add dev command" } else { dev_cmd }; // Determine appropriate descriptions based on project type let dev_description = if command_uses_latex_tool(dev_cmd) { "Compile document" } else { "Run development server" }; let enable_bun_testing_gate = template_uses_bun(setup_cmd, dev_cmd, &deps); let mut out = String::from("version = 1\n\n"); out.push_str("[[tasks]]\n"); out.push_str("name = \"setup\"\n"); out.push_str(&format!("command = \"{}\"\n", toml_escape(setup_cmd))); out.push_str("description = \"Install tools and dependencies\"\n"); out.push_str("shortcuts = [\"s\"]\n"); if command_needs_interactive(setup_cmd) { out.push_str("interactive = true\n"); } if !deps.is_empty() { out.push_str("dependencies = ["); out.push_str( &deps .iter() .map(|d| format!("\"{}\"", dep_name(d))) .collect::<Vec<_>>() .join(", "), ); out.push_str("]\n"); } out.push('\n'); out.push_str("[[tasks]]\n"); out.push_str("name = \"dev\"\n"); out.push_str(&format!("command = \"{}\"\n", toml_escape(dev_cmd))); out.push_str(&format!("description = \"{dev_description}\"\n")); out.push_str("dependencies = [\"setup\"]\n"); out.push_str("shortcuts = [\"d\"]\n"); if command_needs_interactive(dev_cmd) { out.push_str("interactive = true\n"); } if !deps.is_empty() { out.push('\n'); out.push_str("[deps]\n"); for dep in deps { match dep { DepSpec::Single(name, cmd) => { out.push_str(&format!("{name} = \"{cmd}\"\n")); } DepSpec::Multiple(name, cmds) => { let joined = cmds .iter() .map(|c| format!("\"{c}\"")) .collect::<Vec<_>>() .join(", "); out.push_str(&format!("{name} = [{joined}]\n")); } } } } ensure_codex_flow_baseline(&out, enable_bun_testing_gate) } fn contains_toml_section(content: &str, section_header: &str) -> bool { content.lines().any(|line| line.trim() == section_header) } fn append_toml_section_if_missing(out: &mut String, section_header: &str, section_body: &str) { if contains_toml_section(out, section_header) { return; } if !out.ends_with('\n') { out.push('\n'); } if !out.ends_with("\n\n") { out.push('\n'); } out.push_str(section_body.trim_end()); out.push('\n'); } fn ensure_codex_flow_baseline(content: &str, enable_bun_testing_gate: bool) -> String { let mut out = ensure_trailing_newline(content.to_string()); append_toml_section_if_missing( &mut out, "[skills]", r#"[skills] sync_tasks = true install = ["quality-bun-feature-delivery"]"#, ); append_toml_section_if_missing( &mut out, "[skills.codex]", r#"[skills.codex] generate_openai_yaml = true force_reload_after_sync = true task_skill_allow_implicit_invocation = false"#, ); append_toml_section_if_missing( &mut out, "[commit.skill_gate]", r#"[commit.skill_gate] mode = "block" required = ["quality-bun-feature-delivery"]"#, ); append_toml_section_if_missing( &mut out, "[commit.skill_gate.min_version]", r#"[commit.skill_gate.min_version] quality-bun-feature-delivery = 2"#, ); if enable_bun_testing_gate { append_toml_section_if_missing( &mut out, "[commit.testing]", r#"[commit.testing] mode = "block" runner = "bun" bun_repo_strict = true require_related_tests = true ai_scratch_test_dir = ".ai/test" run_ai_scratch_tests = true allow_ai_scratch_to_satisfy_gate = false max_local_gate_seconds = 20"#, ); } ensure_trailing_newline(out) } fn template_uses_bun(setup_cmd: &str, dev_cmd: &str, deps: &[DepSpec]) -> bool { if command_mentions_tool(setup_cmd, "bun") || command_mentions_tool(dev_cmd, "bun") { return true; } deps.iter().any(|dep| match dep { DepSpec::Single(name, cmd) => { name.eq_ignore_ascii_case("bun") || cmd.eq_ignore_ascii_case("bun") } DepSpec::Multiple(name, cmds) => { name.eq_ignore_ascii_case("bun") || cmds.iter().any(|cmd| cmd.eq_ignore_ascii_case("bun")) } }) } fn detect_bun_context(project_root: &Path, content: &str) -> bool { if project_root.join("bun.lock").exists() || project_root.join("bun.lockb").exists() { return true; } if project_root.join("build.zig").exists() && project_root.join("src/bun.js").exists() { return true; } let lowered = content.to_ascii_lowercase(); lowered.contains("bun install") || lowered.contains("bun run") || lowered.contains("bun dev") || lowered.contains("bun test") } fn command_needs_interactive(command: &str) -> bool { let lower = command.to_ascii_lowercase(); lower.contains("read -p") || lower.contains("read -s") || lower.contains("fzf") || lower.contains("password") } fn dep_name(dep: &DepSpec) -> &'static str { match dep { DepSpec::Single(name, _) => name, DepSpec::Multiple(name, _) => name, } } fn toml_escape(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } fn ensure_trailing_newline(mut content: String) -> String { if !content.ends_with('\n') { content.push('\n'); } content } fn prompt_yes_no(message: &str, default_yes: bool) -> Result<bool> { let prompt = if default_yes { "[Y/n]" } else { "[y/N]" }; print!("{message} {prompt}: "); io::stdout().flush()?; if io::stdin().is_terminal() { return read_yes_no_key(default_yes); } let mut input = String::new(); io::stdin().read_line(&mut input)?; let answer = input.trim().to_ascii_lowercase(); if answer.is_empty() { return Ok(default_yes); } Ok(answer == "y" || answer == "yes") } fn prompt_optional(message: &str) -> Result<String> { print!("{message}: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; Ok(input.trim().to_string()) } fn prompt_line(message: &str, default: Option<&str>) -> Result<String> { if let Some(default) = default { print!("{message} [{default}]: "); } else { print!("{message}: "); } io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if trimmed.is_empty() { return Ok(default.unwrap_or("").to_string()); } Ok(trimmed.to_string()) } fn read_yes_no_key(default_yes: bool) -> Result<bool> { enable_raw_mode().context("failed to enable raw mode")?; let mut selection = default_yes; let mut echo_char: Option<char> = None; loop { if let CEvent::Key(key) = event::read()? { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { selection = true; echo_char = Some('y'); break; } KeyCode::Char('n') | KeyCode::Char('N') => { selection = false; echo_char = Some('n'); break; } KeyCode::Enter => { break; } KeyCode::Esc => { selection = false; break; } _ => {} } } } disable_raw_mode().context("failed to disable raw mode")?; if let Some(ch) = echo_char { println!("{ch}"); } else { println!(); } Ok(selection) } fn prompt_line_optional(message: &str, default: Option<&str>) -> Result<Option<String>> { let value = prompt_line(message, default)?; Ok(normalize_optional(value)) } fn prompt_u16_optional(message: &str, default: Option<u16>) -> Result<Option<u16>> { let default_str = default.map(|v| v.to_string()); let value = prompt_line_optional(message, default_str.as_deref())?; match value { Some(text) => text.parse::<u16>().map(Some).context("invalid port value"), None => Ok(None), } } fn normalize_optional(value: String) -> Option<String> { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn format_alias_lines(aliases: &std::collections::HashMap<String, String>) -> Vec<String> { let mut ordered = BTreeMap::new(); for (name, target) in aliases { ordered.insert(name, target); } ordered .into_iter() .map(|(name, target)| format!("alias {name}='{}'", escape_single_quotes(target))) .collect() } fn escape_single_quotes(value: &str) -> String { value.replace('\'', "'\\''") } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use tempfile::tempdir; #[test] fn formats_alias_lines_in_order() { let mut aliases = HashMap::new(); aliases.insert("fr".to_string(), "f run".to_string()); aliases.insert("ft".to_string(), "f tasks".to_string()); let lines = format_alias_lines(&aliases); assert_eq!( lines, vec![ "alias fr='f run'".to_string(), "alias ft='f tasks'".to_string() ] ); } #[test] fn escapes_single_quotes_in_commands() { let cmd = "echo 'hello'"; assert_eq!(escape_single_quotes(cmd), "echo '\\''hello'\\''"); } #[test] fn render_flow_toml_includes_codex_skill_baseline() { let toml = render_flow_toml("cargo build --locked", "cargo run", vec![]); assert!(toml.contains("[skills]")); assert!(toml.contains("[skills.codex]")); assert!(toml.contains("[commit.skill_gate]")); assert!(toml.contains("[commit.skill_gate.min_version]")); assert!(!toml.contains("[commit.testing]")); } #[test] fn render_flow_toml_enables_bun_testing_gate_for_bun_templates() { let toml = render_flow_toml( "bun install", "bun run dev", vec![DepSpec::Single("bun", "bun")], ); assert!(toml.contains("[commit.testing]")); assert!(toml.contains("runner = \"bun\"")); assert!(toml.contains("mode = \"block\"")); } #[test] fn upgrade_existing_flow_toml_adds_codex_baseline() { let dir = tempdir().expect("tempdir"); let config_path = dir.path().join("flow.toml"); fs::write( &config_path, r#"version = 1 [[tasks]] name = "setup" command = "echo setup" "#, ) .expect("write flow.toml"); let changed = maybe_upgrade_existing_flow_toml(dir.path(), &config_path) .expect("upgrade should succeed"); assert!(changed, "existing file should be upgraded"); let updated = fs::read_to_string(&config_path).expect("read updated flow.toml"); assert!(updated.contains("[skills]")); assert!(updated.contains("[skills.codex]")); assert!(updated.contains("[commit.skill_gate]")); assert!(updated.contains("[commit.skill_gate.min_version]")); assert!(!updated.contains("[commit.testing]")); } #[test] fn upgrade_existing_flow_toml_adds_bun_testing_gate_in_bun_context() { let dir = tempdir().expect("tempdir"); let config_path = dir.path().join("flow.toml"); fs::write( &config_path, r#"version = 1 [[tasks]] name = "setup" command = "bun install" "#, ) .expect("write flow.toml"); fs::write(dir.path().join("bun.lock"), "").expect("write bun.lock"); let changed = maybe_upgrade_existing_flow_toml(dir.path(), &config_path) .expect("upgrade should succeed"); assert!(changed, "existing file should be upgraded"); let updated = fs::read_to_string(&config_path).expect("read updated flow.toml"); assert!(updated.contains("[commit.testing]")); assert!(updated.contains("runner = \"bun\"")); } #[test] fn run_prefers_existing_setup_task_without_flow_bootstrap() { let dir = tempdir().expect("tempdir"); let config_path = dir.path().join("flow.toml"); fs::write( &config_path, r#"version = 1 [[tasks]] name = "setup" command = "printf ok > setup-ran.txt" "#, ) .expect("write flow.toml"); run(SetupOpts { config: config_path.clone(), target: None, }) .expect("setup should delegate to project task"); assert!( dir.path().join("setup-ran.txt").exists(), "project setup task should run" ); assert!( !dir.path().join(".ai").exists(), "flow bootstrap should not create .ai when project setup exists" ); assert!( !dir.path().join(".gitignore").exists(), "flow bootstrap should not rewrite .gitignore when project setup exists" ); let flow_toml = fs::read_to_string(&config_path).expect("read flow.toml"); assert!( !flow_toml.contains("[skills]"), "flow setup baseline should not be injected when project setup exists" ); } } ================================================ FILE: src/skills.rs ================================================ //! Codex skills management. //! //! Skills are stored in .ai/skills/<name>/SKILL.md (gitignored by default). use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use serde_json::json; use crate::cli::{SkillsAction, SkillsCommand, SkillsFetchAction, SkillsFetchCommand}; use crate::commit::configured_codex_bin_for_workdir; use crate::config; use crate::start; const DEFAULT_ENV_SKILL: &str = include_str!("../.ai/skills/env/skill.md"); const DEFAULT_QUALITY_BUN_FEATURE_DELIVERY_SKILL: &str = include_str!("../.ai/skills/quality-bun-feature-delivery/skill.md"); const DEFAULT_PR_MARKDOWN_BODY_FILE_SKILL: &str = include_str!("../.ai/skills/pr-markdown-body-file/skill.md"); #[derive(Debug, Default)] pub struct SkillsEnforceSummary { pub task_skills_created: usize, pub task_skills_updated: usize, pub installed_skills: Vec<String>, } impl SkillsEnforceSummary { pub fn is_noop(&self) -> bool { self.task_skills_created == 0 && self.task_skills_updated == 0 && self.installed_skills.is_empty() } } #[derive(Debug, Clone, Copy)] struct SkillSyncOptions { generate_openai_yaml: bool, task_skill_allow_implicit_invocation: bool, } impl Default for SkillSyncOptions { fn default() -> Self { Self { generate_openai_yaml: true, task_skill_allow_implicit_invocation: false, } } } /// Run the skills subcommand. pub fn run(cmd: SkillsCommand) -> Result<()> { let action = cmd.action.unwrap_or(SkillsAction::List); match action { SkillsAction::List => list_skills()?, SkillsAction::New { name, description } => new_skill(&name, description.as_deref())?, SkillsAction::Show { name } => show_skill(&name)?, SkillsAction::Edit { name } => edit_skill(&name)?, SkillsAction::Remove { name } => remove_skill(&name)?, SkillsAction::Install { name } => install_skill(&name)?, SkillsAction::Publish { name } => publish_skill(&name)?, SkillsAction::Search { query } => list_remote_skills(query.as_deref())?, SkillsAction::Sync => sync_skills()?, SkillsAction::Reload => reload_skills()?, SkillsAction::Fetch(fetch) => fetch_skills(&fetch)?, } Ok(()) } /// Get the skills directory for the current project. fn get_skills_dir() -> Result<PathBuf> { let cwd = std::env::current_dir().context("failed to get current directory")?; Ok(get_skills_dir_at(&cwd)) } fn get_skills_dir_at(project_root: &Path) -> PathBuf { project_root.join(".ai").join("skills") } pub fn read_skill_content_at(project_root: &Path, name: &str) -> Result<Option<String>> { let skill_dir = get_skills_dir_at(project_root).join(name); let Some(skill_file) = find_skill_file(&skill_dir) else { return Ok(None); }; let content = fs::read_to_string(&skill_file) .with_context(|| format!("failed to read {}", skill_file.display()))?; Ok(Some(content)) } pub fn read_skill_frontmatter_field_at( project_root: &Path, name: &str, field: &str, ) -> Result<Option<String>> { let Some(content) = read_skill_content_at(project_root, name)? else { return Ok(None); }; Ok(parse_frontmatter_field(&content, field)) } pub fn read_skill_version_at(project_root: &Path, name: &str) -> Result<Option<u32>> { let Some(raw) = read_skill_frontmatter_field_at(project_root, name, "version")? else { return Ok(None); }; let trimmed = raw.trim().trim_matches('"').trim_matches('\''); if trimmed.is_empty() { return Ok(None); } match trimmed.parse::<u32>() { Ok(version) => Ok(Some(version)), Err(_) => Ok(None), } } fn skill_file_lower(skill_dir: &Path) -> PathBuf { skill_dir.join("skill.md") } fn skill_file_upper(skill_dir: &Path) -> PathBuf { skill_dir.join("SKILL.md") } fn has_exact_skill_filename(skill_dir: &Path, filename: &str) -> Result<bool> { if !skill_dir.exists() { return Ok(false); } for entry in fs::read_dir(skill_dir)? { let entry = entry?; if entry.file_name().to_string_lossy() == filename { return Ok(true); } } Ok(false) } fn find_skill_file(skill_dir: &Path) -> Option<PathBuf> { let upper = skill_file_upper(skill_dir); if upper.exists() { return Some(upper); } let lower = skill_file_lower(skill_dir); if lower.exists() { return Some(lower); } None } fn normalize_single_skill_file(skill_dir: &Path) -> Result<bool> { if !skill_dir.exists() { return Ok(false); } let lower = skill_file_lower(skill_dir); let upper = skill_file_upper(skill_dir); let lower_exact = has_exact_skill_filename(skill_dir, "skill.md")?; let upper_exact = has_exact_skill_filename(skill_dir, "SKILL.md")?; if upper_exact && lower_exact { fs::remove_file(&lower)?; return Ok(true); } if upper_exact { return Ok(false); } if !lower_exact { return Ok(false); } // Case-only renames are unreliable on case-insensitive filesystems, so // rename through a temporary filename. let tmp = skill_dir.join(".flow-skill-case-tmp.md"); if tmp.exists() { fs::remove_file(&tmp)?; } fs::rename(&lower, &tmp)?; fs::rename(&tmp, &upper)?; Ok(true) } fn normalize_skill_files(skills_dir: &Path) -> Result<usize> { if !skills_dir.exists() { return Ok(0); } let mut renamed = 0usize; for entry in fs::read_dir(skills_dir).context("failed to read skills directory")? { let entry = entry?; let path = entry.path(); if path.is_dir() && normalize_single_skill_file(&path)? { renamed += 1; } } Ok(renamed) } /// Ensure symlinks exist from .claude/skills and .codex/skills to .ai/skills fn ensure_symlinks() -> Result<()> { let cwd = std::env::current_dir()?; ensure_symlinks_at(&cwd) } fn ensure_symlinks_at(project_root: &Path) -> Result<()> { let ai_skills = project_root.join(".ai").join("skills"); if !ai_skills.exists() { return Ok(()); } // Create .claude/skills -> .ai/skills let claude_dir = project_root.join(".claude"); let claude_skills = claude_dir.join("skills"); create_symlink_if_needed(&ai_skills, &claude_dir, &claude_skills)?; // Create .codex/skills -> .ai/skills let codex_dir = project_root.join(".codex"); let codex_skills = codex_dir.join("skills"); create_symlink_if_needed(&ai_skills, &codex_dir, &codex_skills)?; Ok(()) } fn merge_skill_entries_into_existing_dir(source_dir: &Path, existing_dir: &Path) -> Result<()> { if !existing_dir.exists() { fs::create_dir_all(existing_dir)?; } for entry in fs::read_dir(source_dir).context("failed to read source skills directory")? { let entry = entry?; let source_path = entry.path(); let name = entry.file_name(); let dest_path = existing_dir.join(name); if dest_path.exists() || dest_path.is_symlink() { continue; } #[cfg(unix)] { use std::os::unix::fs::symlink; symlink(&source_path, &dest_path)?; } #[cfg(windows)] { if source_path.is_dir() { use std::os::windows::fs::symlink_dir; symlink_dir(&source_path, &dest_path)?; } else { use std::os::windows::fs::symlink_file; symlink_file(&source_path, &dest_path)?; } } } Ok(()) } /// Create a symlink if it doesn't exist or points elsewhere. fn create_symlink_if_needed( target: &PathBuf, parent_dir: &PathBuf, link_path: &PathBuf, ) -> Result<()> { // Create parent directory if needed if !parent_dir.exists() { fs::create_dir_all(parent_dir)?; } // Check if symlink already exists and points to correct target if link_path.is_symlink() { if let Ok(existing_target) = fs::read_link(link_path) { if existing_target == *target || existing_target == PathBuf::from("../.ai/skills") { return Ok(()); // Already correct } } // Wrong target, remove it fs::remove_file(link_path)?; } else if link_path.exists() { // It's a real directory, keep the directory and merge missing local skills into it. if link_path.is_dir() { merge_skill_entries_into_existing_dir(target, link_path)?; } return Ok(()); } // Create relative symlink: .claude/skills -> ../.ai/skills #[cfg(unix)] { use std::os::unix::fs::symlink; symlink("../.ai/skills", link_path)?; } #[cfg(windows)] { use std::os::windows::fs::symlink_dir; symlink_dir(target, link_path)?; } Ok(()) } /// List all skills in the project. fn list_skills() -> Result<()> { let skills_dir = get_skills_dir()?; if !skills_dir.exists() { println!("No skills found. Create one with: f skills new <name>"); return Ok(()); } let entries = fs::read_dir(&skills_dir).context("failed to read skills directory")?; let mut skills: Vec<(String, Option<String>)> = Vec::new(); for entry in entries { let entry = entry?; let path = entry.path(); if path.is_dir() { let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string(); let description = find_skill_file(&path).and_then(|skill_file| parse_skill_description(&skill_file)); skills.push((name, description)); } } if skills.is_empty() { println!("No skills found. Create one with: f skills new <name>"); return Ok(()); } skills.sort_by(|a, b| a.0.cmp(&b.0)); println!("Skills in .ai/skills/:\n"); for (name, desc) in skills { if let Some(d) = desc { println!(" {} - {}", name, d); } else { println!(" {}", name); } } Ok(()) } /// Parse the description from a skill file. fn parse_skill_description(path: &Path) -> Option<String> { let content = fs::read_to_string(path).ok()?; // Look for description in YAML frontmatter if content.starts_with("---") { let parts: Vec<&str> = content.splitn(3, "---").collect(); if parts.len() >= 2 { for line in parts[1].lines() { let line = line.trim(); if line.starts_with("description:") { return Some(line.trim_start_matches("description:").trim().to_string()); } } } } None } fn resolve_skill_sync_options(skills_cfg: Option<&config::SkillsConfig>) -> SkillSyncOptions { let mut options = SkillSyncOptions::default(); if let Some(codex_cfg) = skills_cfg.and_then(|cfg| cfg.codex.as_ref()) { if let Some(value) = codex_cfg.generate_openai_yaml { options.generate_openai_yaml = value; } if let Some(value) = codex_cfg.task_skill_allow_implicit_invocation { options.task_skill_allow_implicit_invocation = value; } } options } fn should_force_reload_after_sync(skills_cfg: Option<&config::SkillsConfig>) -> bool { skills_cfg .and_then(|cfg| cfg.codex.as_ref()) .and_then(|cfg| cfg.force_reload_after_sync) .unwrap_or(true) } fn task_name_to_display_name(task_name: &str) -> String { task_name .split(['-', '_', ' ']) .filter(|part| !part.is_empty()) .map(|part| { let mut chars = part.chars(); let Some(first) = chars.next() else { return String::new(); }; format!( "{}{}", first.to_ascii_uppercase(), chars.as_str().to_ascii_lowercase() ) }) .filter(|part| !part.is_empty()) .collect::<Vec<_>>() .join(" ") } fn truncate_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { return value.to_string(); } let mut out = String::new(); for (idx, ch) in value.chars().enumerate() { if idx >= max_chars.saturating_sub(1) { break; } out.push(ch); } out.push('…'); out } fn yaml_quote(value: &str) -> String { format!( "\"{}\"", value .replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', " ") ) } fn render_task_skill_openai_yaml( task: &config::TaskConfig, allow_implicit_invocation: bool, ) -> String { let desc = task.description.as_deref().unwrap_or("Flow task"); let display_name = task_name_to_display_name(&task.name); let short_description = truncate_chars(desc, 64); let default_prompt = format!("Use ${} to {}.", task.name, desc.trim_end_matches('.')); format!( "interface:\n display_name: {}\n short_description: {}\n default_prompt: {}\n\npolicy:\n allow_implicit_invocation: {}\n", yaml_quote(&display_name), yaml_quote(&short_description), yaml_quote(&default_prompt), if allow_implicit_invocation { "true" } else { "false" } ) } fn write_task_skill_metadata( skill_dir: &Path, task: &config::TaskConfig, options: SkillSyncOptions, ) -> Result<()> { if !options.generate_openai_yaml { return Ok(()); } let agents_dir = skill_dir.join("agents"); let metadata_path = agents_dir.join("openai.yaml"); let content = render_task_skill_openai_yaml(task, options.task_skill_allow_implicit_invocation); let should_write = match fs::read_to_string(&metadata_path) { Ok(existing) => existing != content, Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, Err(err) => return Err(err.into()), }; if should_write { fs::create_dir_all(&agents_dir)?; fs::write(&metadata_path, content)?; } Ok(()) } /// Create a new skill. fn new_skill(name: &str, description: Option<&str>) -> Result<()> { let skills_dir = get_skills_dir()?; let skill_dir = skills_dir.join(name); if skill_dir.exists() { bail!("Skill '{}' already exists", name); } // Create skill directory fs::create_dir_all(&skill_dir).context("failed to create skill directory")?; // Create SKILL.md let desc = description.unwrap_or("TODO: Add description"); let fm_name = yaml_quote(name); let fm_desc = yaml_quote(desc); let content = format!( r#"--- name: {} description: {} --- # {} ## Instructions TODO: Add instructions for this skill. ## Examples ```bash # Example usage ``` "#, fm_name, fm_desc, name ); let skill_file = skill_file_upper(&skill_dir); fs::write(&skill_file, content).context("failed to write SKILL.md")?; // Ensure symlinks exist for Claude Code and Codex ensure_symlinks()?; println!("Created skill: {}", skill_dir.display()); println!("\nEdit it with: f skills edit {}", name); Ok(()) } /// Show skill details. fn show_skill(name: &str) -> Result<()> { let skills_dir = get_skills_dir()?; let skill_dir = skills_dir.join(name); let Some(skill_file) = find_skill_file(&skill_dir) else { bail!("Skill '{}' not found", name); }; let content = fs::read_to_string(&skill_file).context("failed to read skill file")?; println!("{}", content); Ok(()) } /// Edit a skill in the user's editor. fn edit_skill(name: &str) -> Result<()> { let skills_dir = get_skills_dir()?; let skill_dir = skills_dir.join(name); let skill_file = if normalize_single_skill_file(&skill_dir)? { skill_file_upper(&skill_dir) } else if let Some(path) = find_skill_file(&skill_dir) { path } else { bail!( "Skill '{}' not found. Create it with: f skills new {}", name, name ); }; let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); Command::new(&editor) .arg(&skill_file) .status() .with_context(|| format!("failed to open editor: {}", editor))?; Ok(()) } /// Remove a skill. fn remove_skill(name: &str) -> Result<()> { let skills_dir = get_skills_dir()?; let skill_dir = skills_dir.join(name); if !skill_dir.exists() { bail!("Skill '{}' not found", name); } fs::remove_dir_all(&skill_dir).context("failed to remove skill directory")?; println!("Removed skill: {}", name); Ok(()) } /// Publish a local skill to the shared registry. fn publish_skill(name: &str) -> Result<()> { let skills_dir = get_skills_dir()?; let skill_dir = skills_dir.join(name); let Some(skill_file) = find_skill_file(&skill_dir) else { bail!( "Skill '{}' not found locally. Create it first with: f skills new {}", name, name ); }; let content = fs::read_to_string(&skill_file).context("failed to read skill file")?; // Parse description from YAML frontmatter let description = parse_frontmatter_field(&content, "description") .unwrap_or_else(|| format!("{} skill", name)); // Get auth token let cwd = std::env::current_dir().context("failed to get current directory")?; let token = myflow_token(&cwd).ok_or_else(|| { anyhow::anyhow!( "No myflow token found. Set MYFLOW_TOKEN env var, add myflow_token to flow.toml, or run `f auth login`" ) })?; println!("Publishing skill '{}'...", name); let client = reqwest::blocking::Client::new(); let response = client .put(SKILLS_API_URL) .header("Authorization", format!("Bearer {}", token)) .header("Content-Type", "application/json") .json(&serde_json::json!({ "name": name, "description": description, "content": content, "source": "flow-cli", })) .send() .context("failed to publish skill to registry")?; if !response.status().is_success() { let status = response.status(); let body = response.text().unwrap_or_default(); bail!("Failed to publish skill: HTTP {} — {}", status, body); } println!("Published skill '{}' to registry.", name); println!("Others can install it with: f skills install {}", name); Ok(()) } /// Parse a field value from YAML frontmatter (between --- delimiters). fn parse_frontmatter_field(content: &str, field: &str) -> Option<String> { let trimmed = content.trim_start(); if !trimmed.starts_with("---") { return None; } let after_start = &trimmed[3..]; let end = after_start.find("\n---")?; let frontmatter = &after_start[..end]; for line in frontmatter.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix(&format!("{}:", field)) { let value = rest.trim(); if !value.is_empty() { return Some(value.to_string()); } } } None } /// Get myflow auth token from env, flow.toml, or auth.toml. fn myflow_token(repo_root: &Path) -> Option<String> { // 1. Check env var if let Ok(value) = std::env::var("MYFLOW_TOKEN") { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } // 2. Check flow.toml let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(token) = cfg.options.myflow_token { let trimmed = token.trim().to_string(); if !trimmed.is_empty() { return Some(trimmed); } } } } // 3. Fall back to ~/.config/flow/auth.toml token let config_dir = dirs::config_dir()?.join("flow"); let auth_path = config_dir.join("auth.toml"); if auth_path.exists() { if let Ok(content) = fs::read_to_string(&auth_path) { if let Ok(auth) = toml::from_str::<toml::Value>(&content) { if let Some(token) = auth.get("token").and_then(|v| v.as_str()) { let trimmed = token.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } } } None } const SKILLS_API_URL: &str = "https://myflow.sh/api/skills"; fn codex_skills_dir() -> Option<PathBuf> { if let Some(home) = std::env::var_os("CODEX_HOME").map(PathBuf::from) { return Some(home.join("skills")); } let home = std::env::var_os("HOME").map(PathBuf::from)?; Some(home.join(".codex").join("skills")) } fn read_local_skill_content(name: &str) -> Option<String> { let skills_dir = codex_skills_dir()?; // Codex skills typically store the body in SKILL.md. let candidates = [ skills_dir.join(name).join("SKILL.md"), skills_dir.join(name).join("skill.md"), ]; for path in candidates { if let Ok(content) = fs::read_to_string(&path) { if !content.trim().is_empty() { return Some(content); } } } None } fn load_seq_config(project_root: &Path) -> Result<Option<config::SkillsSeqConfig>> { let flow_toml = project_root.join("flow.toml"); if !flow_toml.exists() { return Ok(None); } let cfg = config::load(&flow_toml)?; Ok(cfg.skills.and_then(|skills| skills.seq)) } fn default_seq_repo() -> PathBuf { if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { return home.join("code").join("seq"); } PathBuf::from("~/code/seq") } fn resolve_path_arg(raw: &str, base: &Path) -> PathBuf { let expanded = config::expand_path(raw); if expanded.is_absolute() { expanded } else { base.join(expanded) } } fn resolve_seq_script_path( project_root: &Path, fetch: &SkillsFetchCommand, seq_cfg: Option<&config::SkillsSeqConfig>, ) -> PathBuf { if let Some(raw) = fetch .script_path .as_deref() .or_else(|| seq_cfg.and_then(|cfg| cfg.script_path.as_deref())) { return resolve_path_arg(raw, project_root); } let repo = if let Some(raw) = fetch .seq_repo .as_deref() .or_else(|| seq_cfg.and_then(|cfg| cfg.seq_repo.as_deref())) { resolve_path_arg(raw, project_root) } else { default_seq_repo() }; repo.join("tools").join("teach_deps.py") } fn fetch_skills(fetch: &SkillsFetchCommand) -> Result<()> { let project_root = std::env::current_dir().context("failed to get current directory")?; let seq_cfg = load_seq_config(&project_root)?; let seq_cfg_ref = seq_cfg.as_ref(); if let Some(mode) = seq_cfg_ref.and_then(|cfg| cfg.mode.as_deref()) { if mode != "local-cli" { println!( "warning: [skills.seq] mode='{}' is not implemented yet; using local-cli", mode ); } } let script_path = resolve_seq_script_path(&project_root, fetch, seq_cfg_ref); if !script_path.exists() { bail!( "seq teach script not found at {} (set [skills.seq].script_path or --script-path)", script_path.display() ); } let out_dir = fetch .out_dir .clone() .or_else(|| seq_cfg_ref.and_then(|cfg| cfg.out_dir.clone())) .unwrap_or_else(|| ".ai/skills".to_string()); let scraper_base_url = fetch .scraper_base_url .clone() .or_else(|| seq_cfg_ref.and_then(|cfg| cfg.scraper_base_url.clone())); let scraper_api_key = fetch .scraper_api_key .clone() .or_else(|| seq_cfg_ref.and_then(|cfg| cfg.scraper_api_key.clone())); let cache_ttl_hours = fetch .cache_ttl_hours .or_else(|| seq_cfg_ref.and_then(|cfg| cfg.cache_ttl_hours)); let allow_direct_fallback = fetch.allow_direct_fallback || seq_cfg_ref .and_then(|cfg| cfg.allow_direct_fallback) .unwrap_or(false); let mem_events_path = fetch .mem_events_path .clone() .or_else(|| seq_cfg_ref.and_then(|cfg| cfg.mem_events_path.clone())); let mut args: Vec<String> = Vec::new(); let force = match &fetch.action { SkillsFetchAction::Dep { deps, ecosystem, force, } => { if deps.is_empty() { bail!("skills fetch dep requires at least one dependency"); } args.push("dep".to_string()); args.extend(deps.iter().cloned()); if let Some(eco) = ecosystem { args.push("--ecosystem".to_string()); args.push(eco.clone()); } *force } SkillsFetchAction::Auto { top, ecosystems, force, } => { args.push("auto".to_string()); let resolved_top = top.or_else(|| seq_cfg_ref.and_then(|cfg| cfg.top)); if let Some(value) = resolved_top { args.push("--top".to_string()); args.push(value.to_string()); } let resolved_ecosystems = ecosystems .clone() .or_else(|| seq_cfg_ref.and_then(|cfg| cfg.ecosystems.clone())); if let Some(value) = resolved_ecosystems { args.push("--ecosystems".to_string()); args.push(value); } *force } SkillsFetchAction::Url { urls, name, force } => { if urls.is_empty() { bail!("skills fetch url requires at least one URL"); } args.push("url".to_string()); args.extend(urls.iter().cloned()); if let Some(value) = name { args.push("--name".to_string()); args.push(value.clone()); } *force } }; args.push("--repo".to_string()); args.push(project_root.display().to_string()); args.push("--out-dir".to_string()); args.push(out_dir.clone()); if force { args.push("--force".to_string()); } if let Some(value) = scraper_base_url { args.push("--scraper-base-url".to_string()); args.push(value); } if let Some(value) = cache_ttl_hours { args.push("--cache-ttl-hours".to_string()); args.push(value.to_string()); } if allow_direct_fallback { args.push("--allow-direct-fallback".to_string()); } if fetch.no_mem_events { args.push("--no-mem-events".to_string()); } if let Some(value) = mem_events_path { args.push("--mem-events-path".to_string()); args.push(value); } let mut cmd = Command::new("python3"); cmd.arg(&script_path); cmd.args(&args); cmd.current_dir(&project_root); if let Some(api_key) = scraper_api_key { cmd.env("SEQ_SCRAPER_API_KEY", api_key); } let status = cmd.status().context("failed to run seq teach script")?; if !status.success() { if let Some(code) = status.code() { bail!("skills fetch failed with exit code {}", code); } bail!("skills fetch failed: process terminated by signal"); } let out_path = { let parsed = PathBuf::from(&out_dir); if parsed.is_absolute() { parsed } else { project_root.join(parsed) } }; let renamed = normalize_skill_files(&out_path)?; ensure_symlinks_at(&project_root)?; println!("Fetched skills via seq into {}", out_path.display()); if renamed > 0 { println!("Normalized {} skill file(s) to SKILL.md", renamed); } println!("Symlinked to .claude/skills/ and .codex/skills/"); Ok(()) } /// Install a skill from the global skills registry. fn install_skill(name: &str) -> Result<()> { let cwd = std::env::current_dir().context("failed to get current directory")?; let installed = install_skill_inner(&cwd, name, false, false)?; if installed { let flow_toml = cwd.join("flow.toml"); let cfg = if flow_toml.exists() { config::load_or_default(&flow_toml) } else { config::Config::default() }; maybe_reload_codex_skills(&cwd, cfg.skills.as_ref(), "skills install"); } Ok(()) } fn install_skill_inner( project_root: &Path, name: &str, allow_existing: bool, quiet: bool, ) -> Result<bool> { let skills_dir = get_skills_dir_at(project_root); let skill_dir = skills_dir.join(name); if skill_dir.exists() { if allow_existing { return Ok(false); } bail!( "Skill '{}' already exists locally. Remove it first with: f skills remove {}", name, name ); } // Prefer local Codex skills (e.g. ~/.codex/skills/<name>/SKILL.md) when present. if let Some(content) = read_local_skill_content(name) { if !quiet { println!("Installing skill '{}' from local Codex skills...", name); } fs::create_dir_all(&skill_dir)?; fs::write(skill_file_upper(&skill_dir), content)?; ensure_symlinks_at(project_root)?; if !quiet { println!("Installed skill: {}", name); println!(" Source: local (~/.codex/skills/)"); } return Ok(true); } if !quiet { println!("Fetching skill '{}' from registry...", name); } // Fetch skill from API. let url = format!("{}?name={}", SKILLS_API_URL, name); let response = reqwest::blocking::get(&url).context("failed to fetch skill from registry")?; if response.status() == 404 { bail!( "Skill '{}' not found in local Codex skills or registry", name ); } if !response.status().is_success() { bail!("Failed to fetch skill: HTTP {}", response.status()); } let skill: SkillResponse = response.json().context("failed to parse skill response")?; // Create skill directory and write SKILL.md. fs::create_dir_all(&skill_dir)?; fs::write(skill_file_upper(&skill_dir), &skill.content)?; // Ensure symlinks ensure_symlinks_at(project_root)?; if !quiet { println!("Installed skill: {}", name); println!( " Source: {}", skill.source.unwrap_or_else(|| "unknown".to_string()) ); if let Some(author) = skill.author { println!(" Author: {}", author); } } Ok(true) } #[derive(Debug, serde::Deserialize)] #[allow(dead_code)] struct SkillResponse { name: String, description: String, content: String, source: Option<String>, author: Option<String>, } /// List available skills from the registry. fn list_remote_skills(search: Option<&str>) -> Result<()> { let url = if let Some(q) = search { format!("{}?search={}", SKILLS_API_URL, q) } else { SKILLS_API_URL.to_string() }; let response = reqwest::blocking::get(&url).context("failed to fetch skills from registry")?; if !response.status().is_success() { bail!("Failed to fetch skills: HTTP {}", response.status()); } let skills: Vec<SkillListItem> = response.json().context("failed to parse skills response")?; if skills.is_empty() { println!("No skills found in registry."); return Ok(()); } println!("Available skills from registry:\n"); for skill in skills { let source = skill.source.unwrap_or_else(|| "unknown".to_string()); println!(" {} [{}]", skill.name, source); println!(" {}", skill.description); println!(); } println!("Install with: f skills install <name>"); Ok(()) } #[derive(Debug, serde::Deserialize)] struct SkillListItem { name: String, description: String, source: Option<String>, } fn codex_write_msg(writer: &mut dyn Write, msg: &serde_json::Value) -> Result<()> { let mut line = serde_json::to_string(msg)?; line.push('\n'); writer.write_all(line.as_bytes())?; writer.flush()?; Ok(()) } fn codex_read_response( lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>, expected_id: u64, deadline: Instant, ) -> Result<serde_json::Value> { loop { if Instant::now() >= deadline { bail!("codex app-server response timed out"); } let line = match lines.next() { Some(Ok(line)) => line, Some(Err(err)) => bail!("failed to read from codex app-server: {}", err), None => bail!("codex app-server closed stdout unexpectedly"), }; if line.trim().is_empty() { continue; } let msg: serde_json::Value = serde_json::from_str(&line) .with_context(|| format!("invalid JSON from codex app-server: {}", line))?; if msg.get("id").and_then(|v| v.as_u64()) == Some(expected_id) { if let Some(err) = msg.get("error") { let message = err .get("message") .and_then(|v| v.as_str()) .unwrap_or("unknown codex app-server error"); bail!("codex app-server error: {}", message); } return Ok(msg); } } } pub(crate) fn reload_codex_skills_for_cwd(cwd: &Path) -> Result<usize> { let codex_bin = configured_codex_bin_for_workdir(cwd); let mut child = Command::new(&codex_bin) .arg("app-server") .current_dir(cwd) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .context("failed to run codex app-server")?; let mut stdin = child.stdin.take().context("missing codex stdin")?; let stdout = child.stdout.take().context("missing codex stdout")?; let mut lines = BufReader::new(stdout).lines(); let handshake_deadline = Instant::now() + Duration::from_secs(15); codex_write_msg( &mut stdin, &json!({ "id": 1, "method": "initialize", "params": { "clientInfo": { "name": "flow", "title": "Flow CLI", "version": "0.1.0" }, "capabilities": { "experimentalApi": true } } }), )?; let _ = codex_read_response(&mut lines, 1, handshake_deadline) .context("codex app-server did not respond to initialize")?; codex_write_msg(&mut stdin, &json!({ "method": "initialized" }))?; let op_deadline = Instant::now() + Duration::from_secs(20); codex_write_msg( &mut stdin, &json!({ "id": 2, "method": "skills/list", "params": { "cwds": [cwd.to_string_lossy().to_string()], "forceReload": true } }), )?; let response = codex_read_response(&mut lines, 2, op_deadline)?; let skill_count = response .pointer("/result/data/0/skills") .and_then(|v| v.as_array()) .map(|skills| skills.len()) .unwrap_or(0); let error_count = response .pointer("/result/data/0/errors") .and_then(|v| v.as_array()) .map(|errors| errors.len()) .unwrap_or(0); let _ = codex_write_msg( &mut stdin, &json!({ "id": 3, "method": "shutdown" }), ); drop(stdin); let _ = child.kill(); let _ = child.wait(); if error_count > 0 { eprintln!( "warning: Codex reported {} skill loader error(s) while reloading", error_count ); } Ok(skill_count) } pub(crate) fn maybe_reload_codex_skills( project_root: &Path, skills_cfg: Option<&config::SkillsConfig>, reason: &str, ) { if !should_force_reload_after_sync(skills_cfg) { return; } match reload_codex_skills_for_cwd(project_root) { Ok(skill_count) => { println!( "Codex skills reloaded ({} skills) after {}", skill_count, reason ); } Err(err) => { eprintln!( "warning: failed to force-reload Codex skills after {}: {}", reason, err ); } } } fn reload_skills() -> Result<()> { let cwd = std::env::current_dir().context("failed to get current directory")?; let skill_count = reload_codex_skills_for_cwd(&cwd)?; println!("Codex skills reloaded ({} skills)", skill_count); Ok(()) } fn render_task_skill(task: &config::TaskConfig) -> String { let desc = task.description.as_deref().unwrap_or("Flow task"); let command = task.command.lines().collect::<Vec<_>>().join("\n"); let fm_name = yaml_quote(&task.name); let fm_desc = yaml_quote(desc); format!( r#"--- name: {} description: {} source: flow.toml --- Run with `f {}`. ```bash {} ``` "#, fm_name, fm_desc, task.name, command ) } fn sync_tasks_to_skills( skills_dir: &Path, tasks: &[config::TaskConfig], options: SkillSyncOptions, ) -> Result<(usize, usize)> { fs::create_dir_all(skills_dir)?; let mut created = 0; let mut updated = 0; for task in tasks { let skill_dir = skills_dir.join(&task.name); fs::create_dir_all(&skill_dir)?; let existed = find_skill_file(&skill_dir).is_some(); let normalized = normalize_single_skill_file(&skill_dir)?; let skill_file = skill_file_upper(&skill_dir); let content = render_task_skill(task); let should_write = match fs::read_to_string(&skill_file) { Ok(existing) => existing != content, Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, Err(err) => return Err(err.into()), }; if should_write { fs::write(&skill_file, content)?; } if !existed { created += 1; } else if should_write || normalized { updated += 1; } write_task_skill_metadata(&skill_dir, task, options)?; } Ok((created, updated)) } /// Sync flow.toml tasks as skills. fn sync_skills() -> Result<()> { let cwd = std::env::current_dir()?; let flow_toml = cwd.join("flow.toml"); if !flow_toml.exists() { bail!("No flow.toml found in current directory"); } // Load flow.toml let cfg = config::load(&flow_toml)?; let skills_dir = get_skills_dir()?; let normalized = normalize_skill_files(&skills_dir)?; let options = resolve_skill_sync_options(cfg.skills.as_ref()); let (created, updated) = sync_tasks_to_skills(&skills_dir, &cfg.tasks, options)?; // Ensure symlinks exist for Claude Code and Codex ensure_symlinks()?; println!("Synced {} tasks from flow.toml", cfg.tasks.len()); if created > 0 { println!(" Created: {}", created); } if updated > 0 { println!(" Updated: {}", updated); } if normalized > 0 { println!(" Normalized: {}", normalized); } println!("\nSymlinked to .claude/skills/ and .codex/skills/"); maybe_reload_codex_skills(&cwd, cfg.skills.as_ref(), "skills sync"); Ok(()) } pub(crate) fn enforce_skills_from_config( project_root: &Path, cfg: &config::Config, ) -> Result<SkillsEnforceSummary> { let Some(skills_cfg) = cfg.skills.as_ref() else { return Ok(SkillsEnforceSummary::default()); }; let skills_dir = get_skills_dir_at(project_root); let mut summary = SkillsEnforceSummary::default(); let _ = normalize_skill_files(&skills_dir)?; if skills_cfg.sync_tasks { let options = resolve_skill_sync_options(Some(skills_cfg)); let (created, updated) = sync_tasks_to_skills(&skills_dir, &cfg.tasks, options)?; summary.task_skills_created = created; summary.task_skills_updated = updated; ensure_symlinks_at(project_root)?; } for name in &skills_cfg.install { let installed = install_skill_inner(project_root, name, true, true)?; if installed { summary.installed_skills.push(name.clone()); } } Ok(summary) } pub fn ensure_default_skills_at(project_root: &Path) -> Result<()> { let skills_dir = get_skills_dir_at(project_root); fs::create_dir_all(&skills_dir)?; start::update_gitignore(project_root)?; let env_dir = skills_dir.join("env"); let env_file = skill_file_upper(&env_dir); let should_write = if env_file.exists() { let content = fs::read_to_string(&env_file).unwrap_or_default(); content.contains("source: flow-default") } else { true }; if should_write { fs::create_dir_all(&env_dir)?; fs::write(&env_file, DEFAULT_ENV_SKILL)?; } let _ = normalize_single_skill_file(&env_dir)?; let quality_dir = skills_dir.join("quality-bun-feature-delivery"); let quality_file = skill_file_upper(&quality_dir); let should_write_quality = if quality_file.exists() { let content = fs::read_to_string(&quality_file).unwrap_or_default(); content.contains("source: flow-default") } else { true }; if should_write_quality { fs::create_dir_all(&quality_dir)?; fs::write(&quality_file, DEFAULT_QUALITY_BUN_FEATURE_DELIVERY_SKILL)?; } let _ = normalize_single_skill_file(&quality_dir)?; let pr_markdown_dir = skills_dir.join("pr-markdown-body-file"); let pr_markdown_file = skill_file_upper(&pr_markdown_dir); let should_write_pr_markdown = if pr_markdown_file.exists() { let content = fs::read_to_string(&pr_markdown_file).unwrap_or_default(); content.contains("source: flow-default") } else { true }; if should_write_pr_markdown { fs::create_dir_all(&pr_markdown_dir)?; fs::write(&pr_markdown_file, DEFAULT_PR_MARKDOWN_BODY_FILE_SKILL)?; } let _ = normalize_single_skill_file(&pr_markdown_dir)?; ensure_symlinks_at(project_root)?; Ok(()) } pub fn auto_sync_skills() { let Ok(cwd) = std::env::current_dir() else { return; }; let mut current = cwd.clone(); let flow_toml = loop { let candidate = current.join("flow.toml"); if candidate.exists() { break Some(candidate); } if !current.pop() { break None; } }; let Some(flow_toml) = flow_toml else { return; }; let Some(project_root) = flow_toml.parent() else { return; }; let cfg = match config::load(&flow_toml) { Ok(cfg) => Some(cfg), Err(err) => { tracing::debug!(?err, "failed to load flow.toml for skills sync"); None } }; if let Err(err) = ensure_default_skills_at(project_root) { tracing::debug!(?err, "failed to auto-sync default skills"); } if let Some(cfg) = cfg { if let Err(err) = enforce_skills_from_config(project_root, &cfg) { tracing::debug!(?err, "failed to auto-sync configured skills"); } } } pub fn ensure_project_skills_at( project_root: &Path, cfg: &config::Config, ) -> Result<SkillsEnforceSummary> { ensure_default_skills_at(project_root)?; enforce_skills_from_config(project_root, cfg) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn sample_task(name: &str, description: Option<&str>) -> config::TaskConfig { config::TaskConfig { name: name.to_string(), command: "echo hi".to_string(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: description.map(|v| v.to_string()), shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, } } #[test] fn task_openai_yaml_defaults_to_no_implicit_invocation() { let task = sample_task("deploy-all", Some("Deploy all services safely")); let yaml = render_task_skill_openai_yaml(&task, false); assert!(yaml.contains("display_name: \"Deploy All\"")); assert!(yaml.contains("allow_implicit_invocation: false")); assert!( yaml.contains("default_prompt: \"Use $deploy-all to Deploy all services safely.\"") ); } #[test] fn task_openai_yaml_can_enable_implicit_invocation() { let task = sample_task("build-web", Some("Build web assets")); let yaml = render_task_skill_openai_yaml(&task, true); assert!(yaml.contains("allow_implicit_invocation: true")); } #[test] fn task_skill_frontmatter_quotes_yaml_sensitive_values() { let task = sample_task( "ooda-run-qwen3-4b", Some("Q4B-baseline: gated confidence fix"), ); let content = render_task_skill(&task); assert!(content.contains("name: \"ooda-run-qwen3-4b\"")); assert!(content.contains("description: \"Q4B-baseline: gated confidence fix\"")); } #[test] fn ensure_default_skills_writes_quality_bun_skill() { let dir = tempdir().expect("tempdir"); ensure_default_skills_at(dir.path()).expect("default skills should be written"); let env = dir.path().join(".ai/skills/env/SKILL.md"); let quality = dir .path() .join(".ai/skills/quality-bun-feature-delivery/SKILL.md"); let pr_markdown = dir.path().join(".ai/skills/pr-markdown-body-file/SKILL.md"); assert!(env.exists(), "env default skill should exist"); assert!(quality.exists(), "quality skill should exist"); assert!( pr_markdown.exists(), "pr markdown default skill should exist" ); let quality_content = fs::read_to_string(&quality).expect("quality skill readable"); assert!(quality_content.contains("name: quality-bun-feature-delivery")); assert!(quality_content.contains("version: 2")); assert!(quality_content.contains("source: flow-default")); let pr_markdown_content = fs::read_to_string(&pr_markdown).expect("pr markdown skill readable"); assert!(pr_markdown_content.contains("name: pr-markdown-body-file")); assert!(pr_markdown_content.contains("version: 1")); assert!(pr_markdown_content.contains("source: flow-default")); } #[test] fn ensure_default_skills_merges_into_existing_codex_skills_directory() { let dir = tempdir().expect("tempdir"); let existing_skill_dir = dir.path().join(".codex/skills/existing-custom-skill"); fs::create_dir_all(&existing_skill_dir).expect("existing skill dir"); fs::write( existing_skill_dir.join("SKILL.md"), "---\nname: existing-custom-skill\n---\n", ) .expect("existing skill"); ensure_default_skills_at(dir.path()).expect("default skills should be written"); let codex_skills_dir = dir.path().join(".codex/skills"); assert!( codex_skills_dir.is_dir(), "codex skills directory should remain a directory" ); assert!( codex_skills_dir .join("existing-custom-skill/SKILL.md") .exists(), "existing codex skill should be preserved" ); assert!( codex_skills_dir.join("env/SKILL.md").exists(), "default env skill should be exposed inside existing codex skills directory" ); assert!( codex_skills_dir .join("quality-bun-feature-delivery/SKILL.md") .exists(), "default quality skill should be exposed inside existing codex skills directory" ); assert!( codex_skills_dir .join("pr-markdown-body-file/SKILL.md") .exists(), "default pr markdown skill should be exposed inside existing codex skills directory" ); } #[test] fn sync_tasks_writes_uppercase_skill_file() { let dir = tempdir().expect("tempdir"); let skills_dir = dir.path().join(".ai/skills"); let tasks = vec![sample_task("hello-task", Some("Say hello"))]; let (created, updated) = sync_tasks_to_skills(&skills_dir, &tasks, SkillSyncOptions::default()) .expect("sync should succeed"); assert_eq!(created, 1); assert_eq!(updated, 0); let task_dir = skills_dir.join("hello-task"); assert!( has_exact_skill_filename(&task_dir, "SKILL.md").expect("exact check should succeed"), "SKILL.md should exist with canonical casing" ); assert!( !has_exact_skill_filename(&task_dir, "skill.md").expect("exact check should succeed"), "legacy lowercase filename should not remain" ); } #[test] fn sync_tasks_migrates_legacy_lowercase_skill_file() { let dir = tempdir().expect("tempdir"); let skills_dir = dir.path().join(".ai/skills"); let task = sample_task("migrate-me", Some("Migrate legacy skill case")); let task_dir = skills_dir.join("migrate-me"); fs::create_dir_all(&task_dir).expect("task dir"); let legacy = skill_file_lower(&task_dir); fs::write(&legacy, render_task_skill(&task)).expect("legacy skill write"); assert!( has_exact_skill_filename(&task_dir, "skill.md").expect("exact check should succeed"), "test fixture should start with lowercase filename" ); let (created, updated) = sync_tasks_to_skills(&skills_dir, &[task], SkillSyncOptions::default()) .expect("sync should succeed"); assert_eq!(created, 0); assert_eq!(updated, 1, "case migration should count as an update"); assert!( has_exact_skill_filename(&task_dir, "SKILL.md").expect("exact check should succeed"), "legacy skill should be migrated to uppercase filename" ); assert!( !has_exact_skill_filename(&task_dir, "skill.md").expect("exact check should succeed"), "legacy lowercase filename should be removed" ); } } ================================================ FILE: src/ssh.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::config; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SshMode { Auto, Force, Https, } pub fn prefer_ssh() -> bool { match ssh_mode() { SshMode::Https => false, SshMode::Force => true, SshMode::Auto => has_identities(), } } pub fn ssh_mode() -> SshMode { if env_truthy("FLOW_FORCE_HTTPS") { return SshMode::Https; } if env_truthy("FLOW_FORCE_SSH") { return SshMode::Force; } if let Some(mode) = std::env::var_os("FLOW_SSH_MODE") { if let Some(parsed) = parse_mode(&mode.to_string_lossy()) { return parsed; } } if let Some(parsed) = ssh_mode_from_config() { return parsed; } SshMode::Auto } pub fn has_identities() -> bool { if let Some(sock) = preferred_agent_sock() { return agent_has_identities(&sock); } false } pub fn ensure_ssh_env() { let env_sock = std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from); let env_sock_valid = env_sock.as_ref().map(|p| p.exists()).unwrap_or(false); let sock = if env_sock_valid { env_sock.clone() } else if let Some(flow_sock) = flow_agent_status() { Some(flow_sock) } else { find_1password_sock() }; let Some(sock) = sock else { return; }; // SAFETY: We're setting env vars at startup before spawning threads unsafe { if !env_sock_valid { std::env::set_var("SSH_AUTH_SOCK", &sock); } if std::env::var_os("GIT_SSH_COMMAND").is_none() { let escaped = shell_escape(&sock); std::env::set_var( "GIT_SSH_COMMAND", format!( "ssh -o IdentityAgent={} -o IdentitiesOnly=yes -o BatchMode=yes", escaped ), ); } } } pub fn ensure_git_ssh_command() -> Result<bool> { if !git_config_writable() { return Ok(false); } let Some(sock) = preferred_agent_sock() else { return Ok(false); }; let desired = format!( "ssh -o IdentityAgent={} -o IdentitiesOnly=yes", shell_escape(&sock) ); if let Some(current) = git_config_get("core.sshCommand")? { let current = current.trim(); if current == desired { return Ok(false); } if !current.is_empty() && !current.contains("IdentityAgent=") { return Ok(false); } } git_config_set("core.sshCommand", &desired)?; Ok(true) } pub fn ensure_git_ssh_command_for_sock(sock: &Path, force: bool) -> Result<bool> { let desired = format!( "ssh -o IdentityAgent={} -o IdentitiesOnly=yes", shell_escape(sock) ); if !force { if let Some(current) = git_config_get("core.sshCommand")? { let current = current.trim(); if !current.is_empty() && !current.contains("IdentityAgent=") { return Ok(false); } } } git_config_set("core.sshCommand", &desired)?; Ok(true) } pub fn ensure_git_ssh_command_wrapper(wrapper: &Path, force: bool) -> Result<bool> { if !git_config_writable() { return Ok(false); } let desired = shell_escape(wrapper); if !force { if let Some(current) = git_config_get("core.sshCommand")? { let current = current.trim(); if current == desired { return Ok(false); } if !current.is_empty() && !current.contains("IdentityAgent=") && !current.contains("flow-ssh") { return Ok(false); } } } git_config_set("core.sshCommand", &desired)?; Ok(true) } pub fn ensure_git_https_insteadof() -> Result<bool> { if !git_config_writable() { return Ok(false); } let desired = ["git@github.com:", "ssh://git@github.com/"]; let mut changed = false; if add_url_rewrite("url.https://github.com/.insteadOf", &desired)? { changed = true; } if add_url_rewrite("url.https://github.com/.pushInsteadOf", &desired)? { changed = true; } Ok(changed) } pub fn clear_git_https_insteadof() -> Result<bool> { if !git_config_writable() { return Ok(false); } let desired = ["git@github.com:", "ssh://git@github.com/"]; let mut changed = false; if remove_url_rewrite("url.https://github.com/.insteadOf", &desired)? { changed = true; } if remove_url_rewrite("url.https://github.com/.pushInsteadOf", &desired)? { changed = true; } Ok(changed) } pub fn ensure_flow_agent() -> Result<PathBuf> { if let Some(sock) = flow_agent_status() { return Ok(sock); } let sock = flow_agent_sock(); if sock.exists() { if probe_agent(&sock) { return Ok(sock); } let _ = fs::remove_file(&sock); } let state_path = flow_agent_state_path(); if let Some(parent) = state_path.parent() { fs::create_dir_all(parent)?; } let output = Command::new("ssh-agent") .args(["-a", sock.to_string_lossy().as_ref(), "-s"]) .output() .context("failed to start ssh-agent")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("ssh-agent failed: {}", stderr.trim()); } let stdout = String::from_utf8_lossy(&output.stdout); let pid = parse_agent_output(&stdout, "SSH_AGENT_PID") .and_then(|val| val.parse::<u32>().ok()) .context("failed to parse ssh-agent pid")?; let sock_path = parse_agent_output(&stdout, "SSH_AUTH_SOCK") .map(PathBuf::from) .unwrap_or_else(|| sock.clone()); let state = FlowAgentState { pid, sock: sock_path.clone(), }; let content = serde_json::to_string_pretty(&state)?; fs::write(&state_path, content)?; Ok(sock_path) } pub fn flow_agent_status() -> Option<PathBuf> { let sock = flow_agent_sock(); if !sock.exists() { return None; } if let Some(state) = load_flow_agent_state() { if !pid_alive(state.pid) { return None; } return Some(state.sock); } if probe_agent(&sock) { return Some(sock); } None } fn preferred_agent_sock() -> Option<PathBuf> { let env_sock = std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from); if env_sock.as_ref().map(|p| p.exists()).unwrap_or(false) { return env_sock; } if let Some(flow_sock) = flow_agent_status() { return Some(flow_sock); } find_1password_sock() } fn ssh_mode_from_config() -> Option<SshMode> { let path = config::default_config_path(); if !path.exists() { return None; } let cfg = config::load(&path).ok()?; let ssh = cfg.ssh?; ssh.mode.as_deref().and_then(parse_mode) } fn parse_mode(raw: &str) -> Option<SshMode> { match raw.trim().to_ascii_lowercase().as_str() { "auto" => Some(SshMode::Auto), "force" | "ssh" => Some(SshMode::Force), "https" => Some(SshMode::Https), _ => None, } } #[derive(Debug, Serialize, Deserialize)] struct FlowAgentState { pid: u32, sock: PathBuf, } fn flow_agent_sock() -> PathBuf { config::global_config_dir().join("ssh").join("agent.sock") } pub fn flow_agent_sock_path() -> PathBuf { flow_agent_sock() } fn flow_agent_state_path() -> PathBuf { config::global_config_dir().join("ssh").join("agent.json") } fn load_flow_agent_state() -> Option<FlowAgentState> { let path = flow_agent_state_path(); let content = fs::read_to_string(&path).ok()?; serde_json::from_str(&content).ok() } fn pid_alive(pid: u32) -> bool { Command::new("kill") .args(["-0", &pid.to_string()]) .status() .map(|status| status.success()) .unwrap_or(false) } fn parse_agent_output(stdout: &str, key: &str) -> Option<String> { for part in stdout.split(&[';', '\n'][..]) { let trimmed = part.trim(); let needle = format!("{}=", key); if let Some(rest) = trimmed.strip_prefix(&needle) { return Some(rest.trim().to_string()); } } None } fn probe_agent(sock: &Path) -> bool { let status = match Command::new("ssh-add") .args(["-l"]) .env("SSH_AUTH_SOCK", sock) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() { Ok(status) => status, Err(_) => return false, }; match status.code() { Some(2) | None => false, _ => true, } } fn agent_has_identities(sock: &Path) -> bool { let status = match Command::new("ssh-add") .args(["-l"]) .env("SSH_AUTH_SOCK", sock) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() { Ok(status) => status, Err(_) => return false, }; matches!(status.code(), Some(0)) } fn find_1password_sock() -> Option<PathBuf> { let candidates = [ "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock", "~/.1password/agent.sock", ]; for candidate in candidates { let path = config::expand_path(candidate); if path.exists() { return Some(path); } } None } fn env_truthy(key: &str) -> bool { let Some(value) = std::env::var_os(key) else { return false; }; let value = value.to_string_lossy().to_lowercase(); matches!(value.as_str(), "1" | "true" | "yes" | "on") } fn git_config_get(key: &str) -> Result<Option<String>> { let output = Command::new("git") .args(["config", "--global", "--get", key]) .output() .context("failed to run git config")?; if !output.status.success() { return Ok(None); } let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); if value.is_empty() { return Ok(None); } Ok(Some(value)) } fn git_config_writable() -> bool { let home = match std::env::var_os("HOME") { Some(val) => PathBuf::from(val), None => return false, }; if !home.is_dir() { return false; } let path = home.join(".gitconfig"); let meta = match fs::symlink_metadata(&path) { Ok(meta) => meta, Err(_) => return true, }; if meta.is_dir() { return false; } if meta.file_type().is_symlink() { match fs::metadata(&path) { Ok(target_meta) => target_meta.is_file(), Err(_) => false, } } else { true } } fn git_config_get_all(key: &str) -> Result<Vec<String>> { let output = Command::new("git") .args(["config", "--global", "--get-all", key]) .output() .context("failed to run git config")?; if !output.status.success() { return Ok(Vec::new()); } let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout .lines() .map(|line| line.trim().to_string()) .filter(|line| !line.is_empty()) .collect()) } fn git_config_set(key: &str, value: &str) -> Result<()> { let status = Command::new("git") .args(["config", "--global", key, value]) .status() .context("failed to run git config")?; if !status.success() { anyhow::bail!("git config --global {} failed", key); } Ok(()) } fn git_config_add(key: &str, value: &str) -> Result<()> { let status = Command::new("git") .args(["config", "--global", "--add", key, value]) .status() .context("failed to run git config")?; if !status.success() { anyhow::bail!("git config --global --add {} failed", key); } Ok(()) } fn git_config_unset_all(key: &str, value: &str) -> Result<()> { let status = Command::new("git") .args(["config", "--global", "--unset-all", key, value]) .status() .context("failed to run git config")?; if !status.success() { anyhow::bail!("git config --global --unset-all {} failed", key); } Ok(()) } fn add_url_rewrite(key: &str, desired: &[&str]) -> Result<bool> { let existing = git_config_get_all(key)?; let mut changed = false; for value in desired { if existing.iter().any(|val| val == value) { continue; } git_config_add(key, value)?; changed = true; } Ok(changed) } fn remove_url_rewrite(key: &str, desired: &[&str]) -> Result<bool> { let existing = git_config_get_all(key)?; let mut changed = false; for value in desired { if existing.iter().any(|val| val == value) { git_config_unset_all(key, value)?; changed = true; } } Ok(changed) } fn shell_escape(path: &Path) -> String { let raw = path.to_string_lossy(); let mut escaped = String::with_capacity(raw.len() + 2); escaped.push('\''); for ch in raw.chars() { if ch == '\'' { escaped.push_str("'\\''"); } else { escaped.push(ch); } } escaped.push('\''); escaped } #[cfg(test)] mod tests { use super::*; struct EnvVarGuard { key: &'static str, previous: Option<std::ffi::OsString>, } impl EnvVarGuard { fn set(key: &'static str, value: &str) -> Self { let previous = std::env::var_os(key); unsafe { std::env::set_var(key, value); } Self { key, previous } } } impl Drop for EnvVarGuard { fn drop(&mut self) { if let Some(value) = self.previous.take() { unsafe { std::env::set_var(self.key, value); } } else { unsafe { std::env::remove_var(self.key); } } } } #[test] fn env_truthy_matches_expected_values() { let _guard = EnvVarGuard::set("FLOW_TEST_BOOL", "true"); assert!(env_truthy("FLOW_TEST_BOOL")); drop(_guard); for value in ["1", "yes", "on", "TRUE"] { let _guard = EnvVarGuard::set("FLOW_TEST_BOOL", value); assert!(env_truthy("FLOW_TEST_BOOL"), "value {}", value); } let _guard = EnvVarGuard::set("FLOW_TEST_BOOL", "0"); assert!(!env_truthy("FLOW_TEST_BOOL")); } #[test] fn prefer_ssh_respects_force_flags() { { let _https = EnvVarGuard::set("FLOW_FORCE_HTTPS", "1"); let _ssh = EnvVarGuard::set("FLOW_FORCE_SSH", "1"); assert!(!prefer_ssh()); } { let _https = EnvVarGuard::set("FLOW_FORCE_HTTPS", "0"); let _ssh = EnvVarGuard::set("FLOW_FORCE_SSH", "1"); assert!(prefer_ssh()); } } #[test] fn ssh_mode_parses_env_override() { { let _https = EnvVarGuard::set("FLOW_SSH_MODE", "https"); assert_eq!(ssh_mode(), SshMode::Https); } { let _force = EnvVarGuard::set("FLOW_SSH_MODE", "force"); assert_eq!(ssh_mode(), SshMode::Force); } } #[test] fn shell_escape_handles_single_quotes() { let path = Path::new("/tmp/has'quote"); let escaped = shell_escape(path); assert_eq!(escaped, "'/tmp/has'\\''quote'"); } #[test] fn parse_agent_output_reads_values() { let sample = "SSH_AUTH_SOCK=/tmp/agent.sock; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=4242; export SSH_AGENT_PID;\n"; assert_eq!( parse_agent_output(sample, "SSH_AUTH_SOCK"), Some("/tmp/agent.sock".to_string()) ); assert_eq!( parse_agent_output(sample, "SSH_AGENT_PID"), Some("4242".to_string()) ); } } ================================================ FILE: src/ssh_keys.rs ================================================ use std::fs; use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use bs58; use chrono::{DateTime, Local, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::cli::SshAction; use crate::sealer_crypto::{get_sealer_id, new_x25519_private_key, seal, unseal}; use crate::{config, env, ssh}; const DEFAULT_TTL_HOURS: u64 = 24; const KEY_PRIVATE: &str = "SSH_PRIVATE_KEY_B64"; const KEY_PRIVATE_SEALED: &str = "SSH_PRIVATE_KEY_SEALED_B64"; const KEY_PRIVATE_SEALED_NONCE: &str = "SSH_PRIVATE_KEY_SEALED_NONCE_B64"; const KEY_PRIVATE_SEALER_ID: &str = "SSH_PRIVATE_KEY_SEALER_ID"; const KEY_PUBLIC: &str = "SSH_PUBLIC_KEY"; const KEY_FINGERPRINT: &str = "SSH_FINGERPRINT"; pub(crate) const DEFAULT_KEY_NAME: &str = "default"; const DEFAULT_SSH_MODE: &str = "force"; #[derive(Debug, Serialize, Deserialize)] struct SealerIdentity { sealer_secret: String, sealer_id: String, } struct SealedKeyPayload { sealed_b64: String, nonce_b64: String, } #[derive(Debug, Serialize, Deserialize)] struct SshKeyUnlock { expires_at: i64, } pub fn run(action: Option<SshAction>) -> Result<()> { match action { Some(SshAction::Setup { name, no_unlock }) => setup(&name, !no_unlock), Some(SshAction::Unlock { name, ttl_hours }) => unlock(&name, ttl_hours), Some(SshAction::Status { name }) => status(&name), None => status(DEFAULT_KEY_NAME), } } pub(crate) fn ensure_default_identity(ttl_hours: u64) -> Result<()> { if ssh::has_identities() { return Ok(()); } let key_name = configured_key_name(); unlock(&key_name, ttl_hours) } fn setup(name: &str, unlock_after: bool) -> Result<()> { let key_name = normalize_name(name); let tmp_dir = std::env::temp_dir().join(format!("flow-ssh-{}", Uuid::new_v4())); fs::create_dir_all(&tmp_dir)?; let key_path = tmp_dir.join("id_ed25519"); let comment = format!( "flow@{}", std::env::var("USER").unwrap_or_else(|_| "flow".to_string()) ); let status = Command::new("ssh-keygen") .args([ "-t", "ed25519", "-N", "", "-C", &comment, "-f", key_path.to_string_lossy().as_ref(), ]) .stdin(Stdio::null()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run ssh-keygen")?; if !status.success() { bail!("ssh-keygen failed"); } let private_key = fs::read_to_string(&key_path) .with_context(|| format!("failed to read {}", key_path.display()))?; let public_key_path = key_path.with_extension("pub"); let public_key = fs::read_to_string(&public_key_path) .with_context(|| format!("failed to read {}", public_key_path.display()))?; let identity = load_or_create_sealer_identity()?; let sealed = seal_private_key(private_key.as_bytes(), &identity)?; let (env_private_plain, env_public, env_fingerprint) = key_env_keys(&key_name); let (env_private_sealed, env_private_nonce, env_private_sealer_id) = key_env_sealed_keys(&key_name); env::set_personal_env_var(&env_private_sealed, &sealed.sealed_b64)?; env::set_personal_env_var(&env_private_nonce, &sealed.nonce_b64)?; env::set_personal_env_var(&env_private_sealer_id, &identity.sealer_id)?; env::set_personal_env_var(&env_public, public_key.trim())?; if let Some(fingerprint) = compute_fingerprint(&public_key_path) { let _ = env::set_personal_env_var(&env_fingerprint, &fingerprint); } let _ = fs::remove_dir_all(&tmp_dir); if let Err(err) = env::delete_personal_env_vars(&[env_private_plain.clone()]) { eprintln!( "Warning: failed to delete legacy plaintext key {}: {}", env_private_plain, err ); } println!("Stored SSH key in cloud as '{}' (sealed).", key_name); println!("Public key:\n{}", public_key.trim()); println!("Add it to GitHub: https://github.com/settings/keys"); ensure_global_ssh_config(&key_name)?; let wrapper = ensure_flow_ssh_wrapper(&key_name)?; let _ = ssh::ensure_git_ssh_command_wrapper(&wrapper, true); if unlock_after { unlock(&key_name, DEFAULT_TTL_HOURS)?; } Ok(()) } fn unlock(name: &str, ttl_hours: u64) -> Result<()> { let key_name = normalize_name(name); require_ssh_key_unlock()?; let (env_private_plain, _env_public, _env_fingerprint) = key_env_keys(&key_name); let (env_private_sealed, env_private_nonce, env_private_sealer_id) = key_env_sealed_keys(&key_name); let vars = env::fetch_personal_env_vars(&[ env_private_sealed.clone(), env_private_nonce.clone(), env_private_sealer_id.clone(), env_private_plain.clone(), ])?; let private_key = if vars.contains_key(&env_private_sealed) || vars.contains_key(&env_private_nonce) { let sealed_b64 = vars.get(&env_private_sealed).ok_or_else(|| { anyhow::anyhow!( "SSH key sealed payload is missing ({}). Run `f ssh setup` again.", env_private_sealed ) })?; let nonce_b64 = vars.get(&env_private_nonce).ok_or_else(|| { anyhow::anyhow!( "SSH key sealed nonce is missing ({}). Run `f ssh setup` again.", env_private_nonce ) })?; let identity = load_sealer_identity()?.ok_or_else(|| { anyhow::anyhow!( "Local SSH seal identity not found. Run `f ssh setup` on this machine first." ) })?; if let Some(expected_id) = vars.get(&env_private_sealer_id) { if expected_id.trim() != identity.sealer_id { bail!( "Stored SSH key is sealed for a different device. Run `f ssh setup` to create a new key or copy {} from the original device.", sealer_identity_path()?.display() ); } } unseal_private_key(sealed_b64, nonce_b64, &identity)? } else if let Some(private_b64) = vars.get(&env_private_plain) { eprintln!( "Warning: using legacy plaintext SSH key from cloud; run `f ssh setup` to seal it." ); STANDARD .decode(private_b64.as_bytes()) .context("failed to decode SSH private key")? } else { bail!("SSH key not found in cloud. Run `f ssh setup` first."); }; let sock = ssh::ensure_flow_agent()?; let result = add_key_to_agent(&private_key, &sock, ttl_hours); let mut private_key = private_key; private_key.fill(0); result?; let wrapper = ensure_flow_ssh_wrapper(&key_name)?; let _ = ssh::ensure_git_ssh_command_wrapper(&wrapper, true); let _ = ssh::clear_git_https_insteadof(); println!("✓ SSH key unlocked (ttl: {}h)", ttl_hours); Ok(()) } fn status(name: &str) -> Result<()> { let key_name = normalize_name(name); let (env_private_plain, env_public, env_fingerprint) = key_env_keys(&key_name); let (env_private_sealed, env_private_nonce, env_private_sealer_id) = key_env_sealed_keys(&key_name); let vars = match env::fetch_personal_env_vars(&[ env_private_plain.clone(), env_public.clone(), env_fingerprint.clone(), env_private_sealed.clone(), env_private_nonce.clone(), env_private_sealer_id.clone(), ]) { Ok(vars) => vars, Err(err) => { println!("Unable to query cloud: {}", err); return Ok(()); } }; let has_plain = vars.contains_key(&env_private_plain); let has_sealed = vars.contains_key(&env_private_sealed) && vars.contains_key(&env_private_nonce); let has_pub = vars.contains_key(&env_public); let fingerprint = vars.get(&env_fingerprint).cloned().unwrap_or_default(); let sealer_id = vars .get(&env_private_sealer_id) .cloned() .unwrap_or_default(); let local_identity = load_sealer_identity().ok().flatten(); let agent = ssh::flow_agent_status(); println!("Key: {}", key_name); println!( "Stored in cloud (sealed): {}", if has_sealed { "yes" } else { "no" } ); println!( "Stored in cloud (plaintext): {}", if has_plain { "yes" } else { "no" } ); println!("Public key stored: {}", if has_pub { "yes" } else { "no" }); if !fingerprint.is_empty() { println!("Fingerprint: {}", fingerprint); } if !sealer_id.is_empty() { println!("Sealer id: {}", sealer_id); } println!( "Local seal identity: {}", if local_identity.is_some() { "yes" } else { "no" } ); match agent { Some(sock) => println!("Flow SSH agent: running ({})", sock.display()), None => println!("Flow SSH agent: not running"), } Ok(()) } fn ensure_global_ssh_config(key_name: &str) -> Result<()> { let config_path = config::default_config_path(); if let Some(parent) = config_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let contents = if config_path.exists() { fs::read_to_string(&config_path) .with_context(|| format!("failed to read {}", config_path.display()))? } else { String::new() }; let updated = upsert_ssh_block(&contents, DEFAULT_SSH_MODE, key_name); if updated != contents { fs::write(&config_path, updated) .with_context(|| format!("failed to write {}", config_path.display()))?; println!( "Configured Flow to use SSH keys from cloud (mode={}, key={}).", DEFAULT_SSH_MODE, key_name ); } Ok(()) } fn upsert_ssh_block(input: &str, mode: &str, key_name: &str) -> String { let mut out: Vec<String> = Vec::new(); let mut in_ssh = false; let mut saw_ssh = false; let mut saw_mode = false; let mut saw_key = false; let ends_with_newline = input.ends_with('\n'); for line in input.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { if in_ssh { if !saw_mode { out.push(format!("mode = \"{}\"", mode)); saw_mode = true; } if !saw_key { out.push(format!("key_name = \"{}\"", key_name)); saw_key = true; } } in_ssh = trimmed == "[ssh]"; if in_ssh { saw_ssh = true; } out.push(line.to_string()); continue; } if in_ssh { if trimmed.starts_with("mode") && trimmed.contains('=') { out.push(format!("mode = \"{}\"", mode)); saw_mode = true; continue; } if trimmed.starts_with("key_name") && trimmed.contains('=') { out.push(format!("key_name = \"{}\"", key_name)); saw_key = true; continue; } } out.push(line.to_string()); } if in_ssh { if !saw_mode { out.push(format!("mode = \"{}\"", mode)); } if !saw_key { out.push(format!("key_name = \"{}\"", key_name)); } } if !saw_ssh { if !out.is_empty() { out.push(String::new()); } out.push("[ssh]".to_string()); out.push(format!("mode = \"{}\"", mode)); out.push(format!("key_name = \"{}\"", key_name)); } let mut rendered = out.join("\n"); if ends_with_newline || rendered.is_empty() { rendered.push('\n'); } rendered } fn configured_key_name() -> String { let config_path = config::default_config_path(); if config_path.exists() { if let Ok(cfg) = config::load(&config_path) { if let Some(ssh_cfg) = cfg.ssh { if let Some(name) = ssh_cfg.key_name { if !name.trim().is_empty() { return name; } } } } } DEFAULT_KEY_NAME.to_string() } fn key_env_keys(name: &str) -> (String, String, String) { if name == "default" { ( format!("FLOW_{}", KEY_PRIVATE), format!("FLOW_{}", KEY_PUBLIC), format!("FLOW_{}", KEY_FINGERPRINT), ) } else { let suffix = sanitize_env_suffix(name); ( format!("FLOW_{}_{}", KEY_PRIVATE, suffix), format!("FLOW_{}_{}", KEY_PUBLIC, suffix), format!("FLOW_{}_{}", KEY_FINGERPRINT, suffix), ) } } fn key_env_sealed_keys(name: &str) -> (String, String, String) { if name == "default" { ( format!("FLOW_{}", KEY_PRIVATE_SEALED), format!("FLOW_{}", KEY_PRIVATE_SEALED_NONCE), format!("FLOW_{}", KEY_PRIVATE_SEALER_ID), ) } else { let suffix = sanitize_env_suffix(name); ( format!("FLOW_{}_{}", KEY_PRIVATE_SEALED, suffix), format!("FLOW_{}_{}", KEY_PRIVATE_SEALED_NONCE, suffix), format!("FLOW_{}_{}", KEY_PRIVATE_SEALER_ID, suffix), ) } } fn normalize_name(name: &str) -> String { let trimmed = name.trim(); if trimmed.is_empty() { "default".to_string() } else { trimmed.to_string() } } fn sanitize_env_suffix(name: &str) -> String { name.chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_uppercase() } else { '_' } }) .collect() } fn ensure_ssh_state_dir() -> Result<PathBuf> { let base = config::ensure_global_state_dir()?; let dir = base.join("ssh"); fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = fs::Permissions::from_mode(0o700); fs::set_permissions(&dir, perms) .with_context(|| format!("failed to chmod {}", dir.display()))?; } Ok(dir) } fn ensure_flow_ssh_wrapper(key_name: &str) -> Result<PathBuf> { let dir = ensure_ssh_state_dir()?; let path = dir.join("flow-ssh"); let sock = ssh::flow_agent_sock_path(); let sock_escaped = escape_double_quotes(&sock.to_string_lossy()); let key_arg = shell_escape_arg(key_name); let content = format!( r#"#!/usr/bin/env bash set -euo pipefail SOCK="{sock}" if [[ -S "$SOCK" ]]; then if SSH_AUTH_SOCK="$SOCK" ssh-add -l >/dev/null 2>&1; then exec /usr/bin/ssh -o IdentityAgent="$SOCK" -o IdentitiesOnly=yes -o BatchMode=yes "$@" fi fi if command -v f >/dev/null 2>&1; then f ssh unlock --name {key} elif command -v flow >/dev/null 2>&1; then flow ssh unlock --name {key} fi exec /usr/bin/ssh -o IdentityAgent="$SOCK" -o IdentitiesOnly=yes -o BatchMode=yes "$@" "#, sock = sock_escaped, key = key_arg ); if !path.exists() || fs::read_to_string(&path).unwrap_or_default() != content { write_executable_file(&path, content.as_bytes())?; } Ok(path) } fn sealer_identity_path() -> Result<PathBuf> { Ok(ensure_ssh_state_dir()?.join("sealer.json")) } fn ssh_unlock_path() -> Result<PathBuf> { Ok(ensure_ssh_state_dir()?.join("unlock.json")) } fn load_ssh_unlock() -> Option<SshKeyUnlock> { let path = ssh_unlock_path().ok()?; let content = fs::read_to_string(&path).ok()?; serde_json::from_str(&content).ok() } fn save_ssh_unlock(expires_at: DateTime<Utc>) -> Result<()> { let path = ssh_unlock_path()?; let entry = SshKeyUnlock { expires_at: expires_at.timestamp(), }; let content = serde_json::to_string_pretty(&entry)?; fs::write(&path, content)?; Ok(()) } fn unlock_expires_at(entry: &SshKeyUnlock) -> Option<DateTime<Utc>> { DateTime::<Utc>::from_timestamp(entry.expires_at, 0) } fn next_local_midnight_utc() -> Result<DateTime<Utc>> { let now = Local::now(); let tomorrow = now .date_naive() .succ_opt() .ok_or_else(|| anyhow::anyhow!("failed to calculate next day"))?; let naive = tomorrow .and_hms_opt(0, 0, 0) .ok_or_else(|| anyhow::anyhow!("failed to build midnight time"))?; let local_dt = Local .from_local_datetime(&naive) .single() .or_else(|| Local.from_local_datetime(&naive).earliest()) .ok_or_else(|| anyhow::anyhow!("failed to resolve local midnight"))?; Ok(local_dt.with_timezone(&Utc)) } fn prompt_touch_id() -> Result<()> { if !cfg!(target_os = "macos") { bail!("Touch ID is not available on this OS"); } if std::env::var("FLOW_NO_TOUCH_ID").is_ok() || !std::io::stdin().is_terminal() { bail!("Touch ID prompt requires an interactive terminal"); } let reason = "Flow needs Touch ID to unlock SSH keys."; let reason = reason.replace('\\', "\\\\").replace('"', "\\\""); let script = format!( r#"ObjC.import('stdlib'); ObjC.import('Foundation'); ObjC.import('LocalAuthentication'); const context = $.LAContext.alloc.init; const policy = $.LAPolicyDeviceOwnerAuthenticationWithBiometrics; const error = Ref(); if (!context.canEvaluatePolicyError(policy, error)) {{ $.exit(2); }} let ok = false; let done = false; context.evaluatePolicyLocalizedReasonReply(policy, "{reason}", function(success, err) {{ ok = success; done = true; }}); const runLoop = $.NSRunLoop.currentRunLoop; while (!done) {{ runLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1)); }} $.exit(ok ? 0 : 1);"# ); let status = Command::new("osascript") .args(["-l", "JavaScript", "-e", &script]) .status() .context("failed to launch Touch ID prompt")?; match status.code() { Some(0) => Ok(()), Some(1) => bail!("Touch ID verification failed"), Some(2) => bail!("Touch ID is not available on this device"), _ => bail!("Touch ID verification failed"), } } fn unlock_ssh_key() -> Result<()> { if !cfg!(target_os = "macos") { println!("Touch ID unlock is not available on this OS."); return Ok(()); } if let Some(entry) = load_ssh_unlock() { if let Some(expires_at) = unlock_expires_at(&entry) { if expires_at > Utc::now() { let local_expiry = expires_at.with_timezone(&Local); println!( "SSH key access already unlocked until {}", local_expiry.format("%Y-%m-%d %H:%M %Z") ); return Ok(()); } } } println!("Touch ID required to unlock SSH keys."); prompt_touch_id()?; let expires_at = next_local_midnight_utc()?; save_ssh_unlock(expires_at)?; let local_expiry = expires_at.with_timezone(&Local); println!( "✓ SSH key access unlocked until {}", local_expiry.format("%Y-%m-%d %H:%M %Z") ); Ok(()) } fn require_ssh_key_unlock() -> Result<()> { if !cfg!(target_os = "macos") { return Ok(()); } if let Some(entry) = load_ssh_unlock() { if let Some(expires_at) = unlock_expires_at(&entry) { if expires_at > Utc::now() { return Ok(()); } } } unlock_ssh_key() } fn load_sealer_identity() -> Result<Option<SealerIdentity>> { let path = sealer_identity_path()?; if !path.exists() { return Ok(None); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let mut identity: SealerIdentity = serde_json::from_str(&content).context("failed to parse SSH sealer identity")?; if identity.sealer_secret.trim().is_empty() { bail!("SSH sealer identity is missing its secret"); } let derived_id = get_sealer_id(&identity.sealer_secret).context("invalid SSH sealer secret")?; if identity.sealer_id != derived_id { identity.sealer_id = derived_id; let updated = serde_json::to_string_pretty(&identity)?; write_private_key(&path, updated.as_bytes())?; } Ok(Some(identity)) } fn load_or_create_sealer_identity() -> Result<SealerIdentity> { if let Some(identity) = load_sealer_identity()? { return Ok(identity); } let identity = create_sealer_identity()?; let path = sealer_identity_path()?; let content = serde_json::to_string_pretty(&identity)?; write_private_key(&path, content.as_bytes())?; Ok(identity) } fn create_sealer_identity() -> Result<SealerIdentity> { let private_key = new_x25519_private_key(); let sealer_secret = format!("sealerSecret_z{}", bs58::encode(&private_key).into_string()); let sealer_id = get_sealer_id(&sealer_secret).context("failed to derive SSH sealer id")?; Ok(SealerIdentity { sealer_secret, sealer_id, }) } fn seal_private_key(private_key: &[u8], identity: &SealerIdentity) -> Result<SealedKeyPayload> { let mut nonce_material = [0u8; 32]; nonce_material[..16].copy_from_slice(Uuid::new_v4().as_bytes()); nonce_material[16..].copy_from_slice(Uuid::new_v4().as_bytes()); let sealed = seal( private_key, &identity.sealer_secret, &identity.sealer_id, &nonce_material, ) .context("failed to seal SSH private key")?; Ok(SealedKeyPayload { sealed_b64: STANDARD.encode(sealed), nonce_b64: STANDARD.encode(nonce_material), }) } fn unseal_private_key( sealed_b64: &str, nonce_b64: &str, identity: &SealerIdentity, ) -> Result<Vec<u8>> { let sealed = STANDARD .decode(sealed_b64.as_bytes()) .context("failed to decode sealed SSH key")?; let nonce_material = STANDARD .decode(nonce_b64.as_bytes()) .context("failed to decode sealed SSH nonce")?; if nonce_material.is_empty() { bail!("sealed SSH nonce is empty"); } let unsealed = unseal( &sealed, &identity.sealer_secret, &identity.sealer_id, &nonce_material, ) .context("failed to unseal SSH private key")?; Ok(unsealed) } fn add_key_to_agent(private_key: &[u8], sock: &Path, ttl_hours: u64) -> Result<()> { let ttl_seconds = ttl_hours.saturating_mul(3600).to_string(); let mut child = Command::new("ssh-add") .arg("-t") .arg(&ttl_seconds) .arg("-") .env("SSH_AUTH_SOCK", sock) .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() .context("failed to run ssh-add")?; { let stdin = child .stdin .as_mut() .context("failed to open ssh-add stdin")?; stdin .write_all(private_key) .context("failed to write SSH key to ssh-add")?; } let status = child.wait().context("failed to wait for ssh-add")?; if !status.success() { bail!("ssh-add failed"); } Ok(()) } fn write_private_key(path: &PathBuf, content: &[u8]) -> Result<()> { let mut options = fs::OpenOptions::new(); options.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let mut file = options .open(path) .with_context(|| format!("failed to create {}", path.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = fs::Permissions::from_mode(0o600); fs::set_permissions(path, perms) .with_context(|| format!("failed to chmod {}", path.display()))?; } file.write_all(content) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn write_executable_file(path: &PathBuf, content: &[u8]) -> Result<()> { let mut options = fs::OpenOptions::new(); options.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o700); } let mut file = options .open(path) .with_context(|| format!("failed to create {}", path.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = fs::Permissions::from_mode(0o700); fs::set_permissions(path, perms) .with_context(|| format!("failed to chmod {}", path.display()))?; } file.write_all(content) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn escape_double_quotes(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } fn shell_escape_arg(value: &str) -> String { if value .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') { value.to_string() } else { format!("'{}'", value.replace('\'', "'\"'\"'")) } } fn compute_fingerprint(public_key_path: &PathBuf) -> Option<String> { let output = Command::new("ssh-keygen") .args(["-lf", public_key_path.to_string_lossy().as_ref()]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); stdout.split_whitespace().nth(1).map(|s| s.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn normalize_name_defaults_to_default() { assert_eq!(normalize_name(""), "default"); assert_eq!(normalize_name(" "), "default"); assert_eq!(normalize_name("work"), "work"); } #[test] fn sanitize_env_suffix_normalizes() { assert_eq!(sanitize_env_suffix("dev-ops"), "DEV_OPS"); assert_eq!(sanitize_env_suffix("aB9"), "AB9"); assert_eq!(sanitize_env_suffix("with space"), "WITH_SPACE"); } #[test] fn key_env_keys_uses_expected_prefixes() { let (priv_key, pub_key, fp) = key_env_keys("default"); assert_eq!(priv_key, "FLOW_SSH_PRIVATE_KEY_B64"); assert_eq!(pub_key, "FLOW_SSH_PUBLIC_KEY"); assert_eq!(fp, "FLOW_SSH_FINGERPRINT"); let (priv_key, pub_key, fp) = key_env_keys("work"); assert_eq!(priv_key, "FLOW_SSH_PRIVATE_KEY_B64_WORK"); assert_eq!(pub_key, "FLOW_SSH_PUBLIC_KEY_WORK"); assert_eq!(fp, "FLOW_SSH_FINGERPRINT_WORK"); } #[test] fn key_env_sealed_keys_uses_expected_prefixes() { let (sealed, nonce, sealer) = key_env_sealed_keys("default"); assert_eq!(sealed, "FLOW_SSH_PRIVATE_KEY_SEALED_B64"); assert_eq!(nonce, "FLOW_SSH_PRIVATE_KEY_SEALED_NONCE_B64"); assert_eq!(sealer, "FLOW_SSH_PRIVATE_KEY_SEALER_ID"); let (sealed, nonce, sealer) = key_env_sealed_keys("work"); assert_eq!(sealed, "FLOW_SSH_PRIVATE_KEY_SEALED_B64_WORK"); assert_eq!(nonce, "FLOW_SSH_PRIVATE_KEY_SEALED_NONCE_B64_WORK"); assert_eq!(sealer, "FLOW_SSH_PRIVATE_KEY_SEALER_ID_WORK"); } #[test] fn seal_private_key_roundtrip() { let identity = create_sealer_identity().expect("identity"); let payload = seal_private_key(b"PRIVATE_KEY", &identity).expect("seal"); let unsealed = unseal_private_key(&payload.sealed_b64, &payload.nonce_b64, &identity).expect("unseal"); assert_eq!(unsealed, b"PRIVATE_KEY"); } #[test] fn write_private_key_sets_permissions() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("id_ed25519_test"); write_private_key(&path, b"PRIVATE").expect("write key"); let content = fs::read_to_string(&path).expect("read key"); assert_eq!(content, "PRIVATE"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = fs::metadata(&path).expect("meta").permissions().mode() & 0o777; assert_eq!(mode, 0o600); } } #[test] fn upsert_ssh_block_adds_when_missing() { let updated = upsert_ssh_block("", "force", "default"); assert!(updated.contains("[ssh]")); assert!(updated.contains("mode = \"force\"")); assert!(updated.contains("key_name = \"default\"")); } #[test] fn upsert_ssh_block_updates_existing_values() { let input = "[ssh]\nmode = \"auto\"\nkey_name = \"work\"\n"; let updated = upsert_ssh_block(input, "force", "default"); assert!(updated.contains("mode = \"force\"")); assert!(updated.contains("key_name = \"default\"")); assert!(!updated.contains("mode = \"auto\"")); assert!(!updated.contains("key_name = \"work\"")); } } ================================================ FILE: src/start.rs ================================================ //! Project bootstrap and initialization. //! //! Creates `.ai/` folder structure with tracked, generated, and internal (gitignored) sections. //! //! Structure: //! .ai/ //! ├── actions/ # TRACKED - fixer/action scripts //! ├── skills/ # GITIGNORED - generated skills //! ├── tools/ # TRACKED - shared tools //! ├── review.md # TRACKED - review instructions //! └── internal/ # GITIGNORED - private data //! ├── sessions/ # AI session data //! ├── checkpoints/ //! ├── db/ //! └── *.json # Various state files use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::{skills, web}; /// Checkpoint names for tracking completed actions. pub mod checkpoints { pub const AI_FOLDER_CREATED: &str = "ai_folder_created"; pub const GITIGNORE_UPDATED: &str = "gitignore_updated"; pub const DB_SCHEMA_CREATED: &str = "db_schema_created"; } /// Run the start command to bootstrap the project. pub fn run() -> Result<()> { let project_root = std::env::current_dir()?; run_at(&project_root) } /// Bootstrap a project at the provided root path. pub fn run_at(project_root: &Path) -> Result<()> { // Create .ai/ folder structure if !has_checkpoint(&project_root, checkpoints::AI_FOLDER_CREATED) { create_ai_folder(&project_root)?; set_checkpoint(&project_root, checkpoints::AI_FOLDER_CREATED)?; println!("✓ Created .ai/ folder structure"); } // Add flow entries to .gitignore if !has_checkpoint(&project_root, checkpoints::GITIGNORE_UPDATED) { update_gitignore(&project_root)?; set_checkpoint(&project_root, checkpoints::GITIGNORE_UPDATED)?; println!("✓ Updated .gitignore"); } // Create database schema if !has_checkpoint(&project_root, checkpoints::DB_SCHEMA_CREATED) { create_db_schema(&project_root)?; set_checkpoint(&project_root, checkpoints::DB_SCHEMA_CREATED)?; println!("✓ Created .ai/internal/db/ with schema"); } skills::ensure_default_skills_at(&project_root)?; // Materialize .claude/ and .codex/ from .ai/ materialize_tool_folders(&project_root)?; web::ensure_web_ui(&project_root)?; println!("\n✓ Project ready"); println!("\nStructure:"); println!(" .ai/"); println!(" ├── actions/ # Tracked - fixer scripts"); println!(" ├── skills/ # Gitignored - generated skills"); println!(" ├── tools/ # Tracked - shared tools"); println!(" ├── flox/ # Tracked - flox manifest"); println!(" ├── web/ # Gitignored - AI web UI"); println!(" ├── docs/ # Tracked - auto-generated docs"); println!(" ├── agents.md # Tracked - agent instructions"); println!(" └── internal/ # Gitignored - private data"); println!(" .claude/ # Gitignored - symlinks to .ai/"); println!(" .codex/ # Gitignored - symlinks to .ai/"); println!(" .flox/ # Gitignored - symlinks to .ai/flox/"); Ok(()) } pub fn is_bootstrapped(project_root: &Path) -> bool { has_checkpoint(project_root, checkpoints::AI_FOLDER_CREATED) && has_checkpoint(project_root, checkpoints::GITIGNORE_UPDATED) && has_checkpoint(project_root, checkpoints::DB_SCHEMA_CREATED) } /// Check if a checkpoint exists. pub fn has_checkpoint(project_root: &Path, name: &str) -> bool { checkpoint_path(project_root, name).exists() } /// Set a checkpoint (creates an empty file). pub fn set_checkpoint(project_root: &Path, name: &str) -> Result<()> { let path = checkpoint_path(project_root, name); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(&path, "")?; Ok(()) } /// Clear a checkpoint. #[allow(dead_code)] pub fn clear_checkpoint(project_root: &Path, name: &str) -> Result<()> { let path = checkpoint_path(project_root, name); if path.exists() { fs::remove_file(&path)?; } Ok(()) } /// Get the path to a checkpoint file. fn checkpoint_path(project_root: &Path, name: &str) -> PathBuf { project_root .join(".ai") .join("internal") .join("checkpoints") .join(name) } /// Create the .ai/ folder structure. fn create_ai_folder(project_root: &Path) -> Result<()> { let ai_dir = project_root.join(".ai"); let internal_dir = ai_dir.join("internal"); // Public folders (tracked in git or generated) let public_dirs = [ ai_dir.clone(), ai_dir.join("actions"), ai_dir.join("skills"), ai_dir.join("tools"), ai_dir.join("flox"), ai_dir.join("web"), ai_dir.join("docs"), ]; // Private folders (gitignored) let internal_dirs = [ internal_dir.clone(), internal_dir.join("checkpoints"), internal_dir.join("sessions"), internal_dir.join("db"), internal_dir.join("commits"), ]; for dir in public_dirs.iter().chain(internal_dirs.iter()) { fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?; } Ok(()) } /// Materialize .claude/, .codex/, and .flox/ folders with symlinks to .ai/ fn materialize_tool_folders(project_root: &Path) -> Result<()> { use std::os::unix::fs::symlink; let ai_dir = project_root.join(".ai"); let agents_source = ai_dir.join("agents.md"); let skills_source = ai_dir.join("skills"); // Materialize .claude/ and .codex/ for tool_dir in [".claude", ".codex"] { let tool_path = project_root.join(tool_dir); fs::create_dir_all(&tool_path)?; // Symlink skills -> ../.ai/skills let skills_link = tool_path.join("skills"); if !skills_link.exists() && skills_source.exists() { let _ = symlink("../.ai/skills", &skills_link); } // Symlink agents.md -> ../.ai/agents.md let agents_link = tool_path.join("agents.md"); if !agents_link.exists() && agents_source.exists() { let _ = symlink("../.ai/agents.md", &agents_link); } } // Materialize .flox/ from .ai/flox/ let flox_source = ai_dir.join("flox"); let manifest_source = flox_source.join("manifest.toml"); if manifest_source.exists() { let flox_dir = project_root.join(".flox"); let flox_env_dir = flox_dir.join("env"); fs::create_dir_all(&flox_env_dir)?; // Symlink manifest.toml -> ../../.ai/flox/manifest.toml let manifest_link = flox_env_dir.join("manifest.toml"); if !manifest_link.exists() { let _ = symlink("../../.ai/flox/manifest.toml", &manifest_link); } // Create env.json files that flox expects let env_json = flox_dir.join("env.json"); if !env_json.exists() { fs::write( &env_json, r#"{ "version": 1, "manifest": "env/manifest.toml", "lockfile": "env/manifest.lock" }"#, )?; } let env_env_json = flox_env_dir.join("env.json"); if !env_env_json.exists() { let manifest_path = flox_env_dir.join("manifest.toml"); let lockfile_path = flox_env_dir.join("manifest.lock"); fs::write( &env_env_json, format!( r#"{{ "version": 1, "manifest": "{}", "lockfile": "{}" }}"#, manifest_path.display(), lockfile_path.display() ), )?; } } Ok(()) } /// Create the database schema files. fn create_db_schema(project_root: &Path) -> Result<()> { let db_dir = project_root.join(".ai/internal/db"); fs::create_dir_all(&db_dir)?; // Create schema.ts with drizzle-orm schema let schema_path = db_dir.join("schema.ts"); if !schema_path.exists() { fs::write(&schema_path, SCHEMA_TEMPLATE)?; } // Create index.ts for database connection let index_path = db_dir.join("index.ts"); if !index_path.exists() { fs::write(&index_path, DB_INDEX_TEMPLATE)?; } // Create package.json for dependencies let package_path = db_dir.join("package.json"); if !package_path.exists() { fs::write(&package_path, DB_PACKAGE_TEMPLATE)?; } Ok(()) } const SCHEMA_TEMPLATE: &str = r#"// .ai/internal/db/schema.ts // Database schema for AI project data using drizzle-orm import { sqliteTable, text, integer, blob } from "drizzle-orm/sqlite-core" // Research notes and findings export const research = sqliteTable("research", { id: text("id").primaryKey(), title: text("title").notNull(), content: text("content").notNull(), source: text("source"), // URL, file path, or reference tags: text("tags"), // JSON array of tags createdAt: integer("created_at", { mode: "timestamp" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp" }), }) // Tasks and todos tracked by agents export const tasks = sqliteTable("tasks", { id: text("id").primaryKey(), title: text("title").notNull(), description: text("description"), status: text("status").notNull().default("pending"), // pending, in_progress, completed, blocked priority: integer("priority").default(0), parentId: text("parent_id"), // for subtasks createdAt: integer("created_at", { mode: "timestamp" }).notNull(), completedAt: integer("completed_at", { mode: "timestamp" }), }) // Files being tracked or generated export const files = sqliteTable("files", { id: text("id").primaryKey(), path: text("path").notNull().unique(), contentHash: text("content_hash"), description: text("description"), generatedBy: text("generated_by"), // agent/tool that created it createdAt: integer("created_at", { mode: "timestamp" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp" }), }) // Key-value store for agent memory/state export const memory = sqliteTable("memory", { key: text("key").primaryKey(), value: text("value").notNull(), // JSON serialized expiresAt: integer("expires_at", { mode: "timestamp" }), createdAt: integer("created_at", { mode: "timestamp" }).notNull(), }) // External service connections and context export const connections = sqliteTable("connections", { id: text("id").primaryKey(), service: text("service").notNull(), // github, x, linear, etc. accountId: text("account_id"), metadata: text("metadata"), // JSON with service-specific data syncedAt: integer("synced_at", { mode: "timestamp" }), createdAt: integer("created_at", { mode: "timestamp" }).notNull(), }) "#; const DB_INDEX_TEMPLATE: &str = r#"// .ai/internal/db/index.ts // Database connection and utilities import { drizzle } from "drizzle-orm/bun-sqlite" import { Database } from "bun:sqlite" import * as schema from "./schema" const sqlite = new Database(".ai/internal/db/db.sqlite") export const db = drizzle(sqlite, { schema }) // Re-export schema for convenience export * from "./schema" // Helper to generate IDs export const genId = () => crypto.randomUUID() // Helper to get current timestamp export const now = () => new Date() "#; const DB_PACKAGE_TEMPLATE: &str = r#"{ "name": "@ai/db", "type": "module", "dependencies": { "drizzle-orm": "^0.38.0" }, "devDependencies": { "drizzle-kit": "^0.30.0" }, "scripts": { "generate": "drizzle-kit generate", "migrate": "drizzle-kit migrate", "studio": "drizzle-kit studio" } } "#; /// Flow gitignore patterns. const FLOW_GITIGNORE_SECTION: &str = "\ # flow .ai/internal/ .ai/web/ .ai/skills/ .claude/ .codex/ .flox/ "; /// Add flow section to .gitignore if not already present. pub(crate) fn update_gitignore(project_root: &Path) -> Result<()> { let gitignore_path = project_root.join(".gitignore"); let content = if gitignore_path.exists() { fs::read_to_string(&gitignore_path)? } else { String::new() }; let required_entries = [ ".ai/internal/", ".ai/web/", ".ai/skills/", ".claude/", ".codex/", ".flox/", ]; // If flow section already exists, make sure required entries are present. if content.contains("# flow") { let mut new_content = content.clone(); let mut updated = false; for entry in required_entries { if !content.lines().any(|line| line.trim() == entry) { if !new_content.ends_with('\n') { new_content.push('\n'); } new_content.push_str(entry); new_content.push('\n'); updated = true; } } if updated { fs::write(&gitignore_path, new_content)?; } return Ok(()); } // Also check if all patterns are already present (legacy) let has_ai_internal = content.lines().any(|l| l.trim() == ".ai/internal/"); let has_web = content.lines().any(|l| l.trim() == ".ai/web/"); let has_skills = content.lines().any(|l| l.trim() == ".ai/skills/"); let has_claude = content.lines().any(|l| l.trim() == ".claude/"); let has_codex = content.lines().any(|l| l.trim() == ".codex/"); let has_flox = content.lines().any(|l| l.trim() == ".flox/"); if has_ai_internal && has_web && has_skills && has_claude && has_codex && has_flox { return Ok(()); } // Add flow section let mut new_content = content; if !new_content.is_empty() && !new_content.ends_with('\n') { new_content.push('\n'); } if !new_content.is_empty() && !new_content.ends_with("\n\n") { new_content.push('\n'); } new_content.push_str(FLOW_GITIGNORE_SECTION); fs::write(&gitignore_path, new_content)?; Ok(()) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn test_checkpoint_lifecycle() { let dir = tempdir().unwrap(); let root = dir.path(); assert!(!has_checkpoint(root, "test_checkpoint")); set_checkpoint(root, "test_checkpoint").unwrap(); assert!(has_checkpoint(root, "test_checkpoint")); clear_checkpoint(root, "test_checkpoint").unwrap(); assert!(!has_checkpoint(root, "test_checkpoint")); } #[test] fn test_create_ai_folder() { let dir = tempdir().unwrap(); let root = dir.path(); create_ai_folder(root).unwrap(); // Public folders assert!(root.join(".ai").exists()); assert!(root.join(".ai/actions").exists()); assert!(root.join(".ai/skills").exists()); assert!(root.join(".ai/tools").exists()); // Internal folders assert!(root.join(".ai/internal").exists()); assert!(root.join(".ai/internal/checkpoints").exists()); assert!(root.join(".ai/internal/sessions").exists()); assert!(root.join(".ai/internal/db").exists()); } #[test] fn test_update_gitignore_new_file() { let dir = tempdir().unwrap(); let root = dir.path(); update_gitignore(root).unwrap(); let content = fs::read_to_string(root.join(".gitignore")).unwrap(); assert!(content.contains("# flow")); assert!(content.contains(".ai/internal/")); assert!(content.contains(".ai/web/")); assert!(content.contains(".ai/skills/")); assert!(content.contains(".claude/")); assert!(content.contains(".codex/")); } #[test] fn test_update_gitignore_existing() { let dir = tempdir().unwrap(); let root = dir.path(); fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); update_gitignore(root).unwrap(); let content = fs::read_to_string(root.join(".gitignore")).unwrap(); assert!(content.contains("node_modules/")); assert!(content.contains("# flow")); assert!(content.contains(".ai/internal/")); assert!(content.contains(".ai/web/")); assert!(content.contains(".ai/skills/")); assert!(content.contains(".claude/")); assert!(content.contains(".codex/")); } #[test] fn test_update_gitignore_already_present() { let dir = tempdir().unwrap(); let root = dir.path(); fs::write( root.join(".gitignore"), "# flow\n.ai/internal/\n.ai/web/\n.ai/skills/\n.claude/\n.codex/\n.flox/\n", ) .unwrap(); update_gitignore(root).unwrap(); let content = fs::read_to_string(root.join(".gitignore")).unwrap(); // Should not duplicate assert_eq!(content.matches("# flow").count(), 1); assert_eq!(content.matches(".ai/internal/").count(), 1); assert_eq!(content.matches(".ai/skills/").count(), 1); } #[test] fn test_update_gitignore_adds_web_when_flow_section_exists() { let dir = tempdir().unwrap(); let root = dir.path(); fs::write( root.join(".gitignore"), "# flow\n.ai/internal/\n.claude/\n.codex/\n", ) .unwrap(); update_gitignore(root).unwrap(); let content = fs::read_to_string(root.join(".gitignore")).unwrap(); assert!(content.contains("# flow")); assert!(content.contains(".ai/internal/")); assert!(content.contains(".ai/web/")); assert!(content.contains(".ai/skills/")); assert!(content.contains(".flox/")); } } ================================================ FILE: src/storage.rs ================================================ use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use shellexpand::tilde; use url::Url; use uuid::Uuid; use crate::cli::{DbAction, DbCommand, JazzStorageAction, JazzStorageKind, PostgresAction}; use crate::{config, env}; const DEFAULT_JAZZ_API_KEY_MIRROR: &str = "jazz-gitedit-prod"; const DEFAULT_JAZZ_SERVER_MIRROR: &str = "https://cloud.jazz.tools"; const DEFAULT_JAZZ_API_KEY_ENV: &str = "cloud@myflow.sh"; const DEFAULT_JAZZ_SERVER_ENV: &str = "https://cloud.jazz.tools"; const DEFAULT_JAZZ_API_KEY_APP: &str = "cloud@myflow.sh"; const DEFAULT_JAZZ_SERVER_APP: &str = "https://cloud.jazz.tools"; const DEFAULT_POSTGRES_PROJECT: &str = "~/org/la/la/server"; const DEFAULT_JAZZ_TOOLS_NPX_SPEC: &str = "jazz-tools@0.20.14"; const JAZZ_TOOLS_NPX_SPEC_ENV: &str = "FLOW_JAZZ_TOOLS_PACKAGE_SPEC"; #[derive(Debug, Clone)] pub(crate) struct JazzAppCredentials { pub(crate) app_id: String, pub(crate) backend_secret: String, pub(crate) admin_secret: String, } pub fn run(cmd: DbCommand) -> Result<()> { match cmd.action { DbAction::Jazz(jazz) => run_jazz(jazz.action), DbAction::Postgres(pg) => run_postgres(pg.action), } } fn run_jazz(action: JazzStorageAction) -> Result<()> { match action { JazzStorageAction::New { kind, name, peer, api_key, environment, } => jazz_new(kind, name, peer, api_key, &environment), } } fn run_postgres(action: PostgresAction) -> Result<()> { match action { PostgresAction::Generate { project } => postgres_generate(project), PostgresAction::Migrate { project, database_url, generate, } => postgres_migrate(project, database_url, generate), } } fn postgres_generate(project: Option<PathBuf>) -> Result<()> { let project_dir = resolve_postgres_project(project)?; println!("Running migrations generate in {}", project_dir.display()); run_bun_script(&project_dir, "db:generate", None) } fn postgres_migrate( project: Option<PathBuf>, database_url: Option<String>, generate: bool, ) -> Result<()> { let project_dir = resolve_postgres_project(project)?; let database_url = resolve_database_url(database_url.as_deref(), &project_dir)?; if generate { println!("Generating migrations in {}", project_dir.display()); run_bun_script(&project_dir, "db:generate", Some(&database_url))?; } println!("Applying migrations in {}", project_dir.display()); run_bun_script(&project_dir, "db:migrate", Some(&database_url)) } fn resolve_postgres_project(project: Option<PathBuf>) -> Result<PathBuf> { let path = match project { Some(path) => path, None => PathBuf::from(tilde(DEFAULT_POSTGRES_PROJECT).as_ref()), }; if path.exists() { return Ok(path); } bail!( "Postgres project path not found: {} (override with --project)", path.display() ) } fn resolve_database_url(database_url: Option<&str>, project_dir: &Path) -> Result<String> { if let Some(url) = database_url { let trimmed = url.trim(); if !trimmed.is_empty() { return Ok(trimmed.to_string()); } } for key in [ "DATABASE_URL", "PLANETSCALE_DATABASE_URL", "PSCALE_DATABASE_URL", ] { if let Ok(url) = std::env::var(key) { if !url.trim().is_empty() { return Ok(url); } } } let env_path = project_dir.join(".env"); if let Some(value) = read_env_value(&env_path, "DATABASE_URL")? { return Ok(value); } bail!( "DATABASE_URL not found (set env, PLANETSCALE_DATABASE_URL, or add it to {})", env_path.display() ) } fn read_env_value(path: &Path, key: &str) -> Result<Option<String>> { if !path.exists() { return Ok(None); } let contents = fs::read_to_string(path) .with_context(|| format!("failed to read env file {}", path.display()))?; for line in contents.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let line = line.strip_prefix("export ").unwrap_or(line); let Some((name, value)) = line.split_once('=') else { continue; }; if name.trim() != key { continue; } let value = strip_quotes(value.trim()); if !value.is_empty() { return Ok(Some(value.to_string())); } } Ok(None) } fn strip_quotes(value: &str) -> &str { let trimmed = value.trim(); trimmed .strip_prefix('"') .and_then(|v| v.strip_suffix('"')) .or_else(|| { trimmed .strip_prefix('\'') .and_then(|v| v.strip_suffix('\'')) }) .unwrap_or(trimmed) } fn run_bun_script(project_dir: &Path, script: &str, database_url: Option<&str>) -> Result<()> { let mut cmd = Command::new("bun"); cmd.args(["run", script]); cmd.current_dir(project_dir); if let Some(url) = database_url { cmd.env("DATABASE_URL", url); } cmd.stdout(Stdio::inherit()); cmd.stderr(Stdio::inherit()); let status = cmd.status().with_context(|| { format!( "failed to run bun script '{}' in {}", script, project_dir.display() ) })?; if !status.success() { bail!("bun run {} failed with status {}", script, status); } Ok(()) } fn jazz_new( kind: JazzStorageKind, name: Option<String>, peer: Option<String>, api_key: Option<String>, environment: &str, ) -> Result<()> { let project = get_project_name()?; let default_name = match kind { JazzStorageKind::Mirror => format!("{}-jazz-mirror", sanitize_name(&project)), JazzStorageKind::EnvStore => format!("{}-jazz-env", sanitize_name(&project)), JazzStorageKind::AppStore => format!("{}-jazz-app", sanitize_name(&project)), }; let name = name.unwrap_or(default_name); let default_server_url = match kind { JazzStorageKind::Mirror => DEFAULT_JAZZ_SERVER_MIRROR, JazzStorageKind::EnvStore => DEFAULT_JAZZ_SERVER_ENV, JazzStorageKind::AppStore => DEFAULT_JAZZ_SERVER_APP, }; let (server_url, peer_api_key) = match peer { Some(peer) => { let api_key = extract_api_key(&peer); (normalize_server_url(&peer), api_key) } None => (default_server_url.to_string(), None), }; let effective_api_key = api_key.or(peer_api_key); let creds = create_jazz_app_credentials(&name)?; match kind { JazzStorageKind::Mirror => { env::set_project_env_var( "JAZZ_MIRROR_APP_ID", &creds.app_id, environment, Some("Jazz2 mirror app id"), )?; env::set_project_env_var( "JAZZ_MIRROR_BACKEND_SECRET", &creds.backend_secret, environment, Some("Jazz2 mirror backend secret"), )?; env::set_project_env_var( "JAZZ_MIRROR_ADMIN_SECRET", &creds.admin_secret, environment, Some("Jazz2 mirror admin secret"), )?; } JazzStorageKind::EnvStore => { env::set_project_env_var( "JAZZ_APP_ID", &creds.app_id, environment, Some("Jazz2 app id"), )?; env::set_project_env_var( "JAZZ_BACKEND_SECRET", &creds.backend_secret, environment, Some("Jazz2 backend secret"), )?; env::set_project_env_var( "JAZZ_ADMIN_SECRET", &creds.admin_secret, environment, Some("Jazz2 admin secret"), )?; } JazzStorageKind::AppStore => { env::set_project_env_var( "JAZZ_APP_APP_ID", &creds.app_id, environment, Some("Jazz2 app-store app id"), )?; env::set_project_env_var( "JAZZ_APP_BACKEND_SECRET", &creds.backend_secret, environment, Some("Jazz2 app-store backend secret"), )?; env::set_project_env_var( "JAZZ_APP_ADMIN_SECRET", &creds.admin_secret, environment, Some("Jazz2 app-store admin secret"), )?; } } if effective_api_key.is_some() { let key = effective_api_key.unwrap_or_else(|| match kind { JazzStorageKind::Mirror => DEFAULT_JAZZ_API_KEY_MIRROR.to_string(), JazzStorageKind::EnvStore => DEFAULT_JAZZ_API_KEY_ENV.to_string(), JazzStorageKind::AppStore => DEFAULT_JAZZ_API_KEY_APP.to_string(), }); env::set_project_env_var( "JAZZ_API_KEY", &key, environment, Some("Jazz2 API key for hosted sync"), )?; } if server_url != default_server_url { let (key, desc) = match kind { JazzStorageKind::Mirror => ( "JAZZ_MIRROR_SERVER_URL", "Custom Jazz2 server URL for mirror app", ), JazzStorageKind::EnvStore => ("JAZZ_SERVER_URL", "Custom Jazz2 server URL"), JazzStorageKind::AppStore => ( "JAZZ_APP_SERVER_URL", "Custom Jazz2 server URL for app-store app", ), }; env::set_project_env_var(key, &server_url, environment, Some(desc))?; } println!("✓ Jazz2 storage initialized for {}", project); Ok(()) } pub(crate) fn create_jazz_app_credentials(name: &str) -> Result<JazzAppCredentials> { println!( "Creating Jazz2 app credentials '{}' (this can take a minute)...", name ); let output = if let Some(path) = find_in_path("jazz-tools") { println!("Running: {} create app --name {}", path.display(), name); { let mut cmd = Command::new(path); cmd.args(["create", "app", "--name", name]); run_command_with_output(cmd) } .context("failed to spawn jazz-tools")? } else { let package_spec = jazz_tools_package_spec(); println!( "Running: npx --yes {} create app --name {}", package_spec, name ); { let mut cmd = Command::new("npx"); cmd.args(["--yes"]); cmd.arg(&package_spec); cmd.args(["create", "app", "--name", name]); run_command_with_output(cmd) } .context("failed to spawn npx")? }; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); bail!( "jazz2 app create failed: {}{}", stdout.trim(), stderr.trim() ); } let stdout = String::from_utf8_lossy(&output.stdout); let app_id = stdout .lines() .rev() .find(|line| !line.trim().is_empty()) .map(|line| line.trim().to_string()) .ok_or_else(|| anyhow::anyhow!("jazz-tools did not return an app id"))?; Ok(JazzAppCredentials { app_id, backend_secret: generate_secret("backend"), admin_secret: generate_secret("admin"), }) } fn jazz_tools_package_spec() -> String { resolve_jazz_tools_package_spec(std::env::var(JAZZ_TOOLS_NPX_SPEC_ENV).ok().as_deref()) } fn resolve_jazz_tools_package_spec(raw: Option<&str>) -> String { raw.map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(DEFAULT_JAZZ_TOOLS_NPX_SPEC) .to_string() } fn run_command_with_output(mut cmd: Command) -> Result<Output> { let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; let mut stdout = child .stdout .take() .ok_or_else(|| anyhow::anyhow!("failed to capture stdout"))?; let mut stderr = child .stderr .take() .ok_or_else(|| anyhow::anyhow!("failed to capture stderr"))?; let stdout_handle = thread::spawn(move || { let mut buf = Vec::new(); let _ = stdout.read_to_end(&mut buf); buf }); let stderr_handle = thread::spawn(move || { let mut buf = Vec::new(); let _ = stderr.read_to_end(&mut buf); buf }); let start = Instant::now(); let mut next_log = Duration::from_secs(10); let status = loop { if let Some(status) = child.try_wait()? { break status; } let elapsed = start.elapsed(); if elapsed >= next_log { println!( "... still creating Jazz worker account ({}s)", elapsed.as_secs() ); next_log += Duration::from_secs(10); } thread::sleep(Duration::from_millis(200)); }; let stdout = stdout_handle.join().unwrap_or_default(); let stderr = stderr_handle.join().unwrap_or_default(); Ok(Output { status, stdout, stderr, }) } fn normalize_server_url(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return DEFAULT_JAZZ_SERVER_ENV.to_string(); } if let Ok(mut parsed) = Url::parse(trimmed) { match parsed.scheme() { "wss" => { let _ = parsed.set_scheme("https"); } "ws" => { let _ = parsed.set_scheme("http"); } _ => {} } parsed.set_query(None); parsed.set_fragment(None); return parsed.to_string().trim_end_matches('/').to_string(); } trimmed.trim_end_matches('/').to_string() } fn extract_api_key(value: &str) -> Option<String> { let parsed = Url::parse(value).ok()?; parsed .query_pairs() .find_map(|(k, v)| (k == "key").then(|| v.to_string())) } fn find_in_path(binary: &str) -> Option<PathBuf> { let path = std::env::var_os("PATH")?; for dir in std::env::split_paths(&path) { let candidate = dir.join(binary); if candidate.is_file() { return Some(candidate); } } None } fn generate_secret(prefix: &str) -> String { format!( "{}-{}{}", prefix, Uuid::new_v4().as_simple(), Uuid::new_v4().as_simple() ) } pub(crate) fn sanitize_name(name: &str) -> String { let mut out = String::new(); let mut last_dash = false; for ch in name.chars() { let ch = ch.to_ascii_lowercase(); if ch.is_ascii_alphanumeric() { out.push(ch); last_dash = false; } else if !last_dash { out.push('-'); last_dash = true; } } let trimmed = out.trim_matches('-').to_string(); if trimmed.is_empty() { "flow-jazz-mirror".to_string() } else { trimmed } } fn find_flow_toml(start: &PathBuf) -> Option<PathBuf> { let mut current = start.clone(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } pub(crate) fn get_project_name() -> Result<String> { let cwd = std::env::current_dir()?; if let Some(flow_path) = find_flow_toml(&cwd) { if let Ok(cfg) = config::load(&flow_path) { if let Some(name) = cfg.project_name { return Ok(name); } if let Some(parent) = flow_path.parent() { if let Some(dir_name) = parent.file_name().and_then(|n| n.to_str()) { return Ok(dir_name.to_string()); } } } } Ok(cwd .file_name() .and_then(|n| n.to_str()) .unwrap_or("flow") .to_string()) } #[cfg(test)] mod tests { use super::{DEFAULT_JAZZ_TOOLS_NPX_SPEC, resolve_jazz_tools_package_spec, sanitize_name}; #[test] fn sanitize_name_keeps_alnum_and_normalizes_separators() { assert_eq!(sanitize_name("Flow Mirror App"), "flow-mirror-app"); assert_eq!(sanitize_name("___"), "flow-jazz-mirror"); } #[test] fn resolve_jazz_tools_package_spec_defaults_to_pinned_version() { assert_eq!( resolve_jazz_tools_package_spec(None), DEFAULT_JAZZ_TOOLS_NPX_SPEC ); assert_eq!( resolve_jazz_tools_package_spec(Some(" ")), DEFAULT_JAZZ_TOOLS_NPX_SPEC ); } #[test] fn resolve_jazz_tools_package_spec_allows_explicit_override() { assert_eq!( resolve_jazz_tools_package_spec(Some("jazz-tools@0.20.15")), "jazz-tools@0.20.15" ); assert_eq!( resolve_jazz_tools_package_spec(Some(" jazz-tools@local ")), "jazz-tools@local" ); } } ================================================ FILE: src/supervisor.rs ================================================ use std::collections::HashMap; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::cli::{DaemonAction, SupervisorAction, SupervisorCommand}; use crate::{config, daemon, projects, running}; #[derive(Debug, Serialize, Deserialize)] pub struct IpcRequest { pub action: SupervisorIpcAction, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SupervisorIpcAction { Ping, StartDaemon { name: String, config_path: Option<String>, }, StopDaemon { name: String, config_path: Option<String>, }, RestartDaemon { name: String, config_path: Option<String>, }, Status { config_path: Option<String>, }, List { config_path: Option<String>, }, } #[derive(Debug, Serialize, Deserialize)] pub struct IpcResponse { pub ok: bool, pub message: Option<String>, pub daemons: Option<Vec<DaemonStatusView>>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DaemonStatusView { pub name: String, pub running: bool, pub pid: Option<u32>, #[serde(default)] pub healthy: Option<bool>, pub health_target: Option<String>, pub description: Option<String>, } #[derive(Debug, Clone)] struct ManagedDaemon { name: String, config_path: Option<PathBuf>, restart: config::DaemonRestartPolicy, retry_remaining: Option<u32>, autostop: bool, disabled: bool, health_failures: u32, restart_attempts: u32, next_restart_at: Option<Instant>, } #[derive(Default)] struct SupervisorState { managed: HashMap<String, ManagedDaemon>, } type SharedState = Arc<Mutex<SupervisorState>>; pub fn run(cmd: SupervisorCommand) -> Result<()> { let action = cmd.action.unwrap_or(SupervisorAction::Status); let socket_path = resolve_socket_path(cmd.socket.as_deref())?; match action { SupervisorAction::Start { boot } => { ensure_supervisor_running(&socket_path, true, boot)?; Ok(()) } SupervisorAction::Run { boot } => run_server(&socket_path, boot), SupervisorAction::Install { boot } => install_launch_agent(&socket_path, boot), SupervisorAction::Uninstall => uninstall_launch_agent(&socket_path), SupervisorAction::Stop => stop_supervisor(&socket_path), SupervisorAction::Status => show_status(&socket_path), } } pub fn ensure_running(boot: bool, announce: bool) -> Result<()> { let socket_path = resolve_socket_path(None)?; ensure_supervisor_running(&socket_path, announce, boot)?; Ok(()) } pub fn ensure_daemon_running(name: &str, config_path: Option<&Path>, announce: bool) -> Result<()> { let socket_path = resolve_socket_path(None)?; ensure_supervisor_running(&socket_path, announce, false)?; let request = IpcRequest { action: SupervisorIpcAction::StartDaemon { name: name.to_string(), config_path: config_path.map(|p| p.display().to_string()), }, }; let response = send_request(&socket_path, &request)?; if !response.ok { bail!( "{}", response .message .unwrap_or_else(|| format!("failed to start daemon {}", name)) ); } if announce { if let Some(message) = response.message { println!("OK {}", message); } } Ok(()) } pub fn stop_daemon_managed(name: &str, config_path: Option<&Path>, announce: bool) -> Result<()> { let socket_path = resolve_socket_path(None)?; ensure_supervisor_running(&socket_path, announce, false)?; let request = IpcRequest { action: SupervisorIpcAction::StopDaemon { name: name.to_string(), config_path: config_path.map(|p| p.display().to_string()), }, }; let response = send_request(&socket_path, &request)?; if !response.ok { bail!( "{}", response .message .unwrap_or_else(|| format!("failed to stop daemon {}", name)) ); } if announce { if let Some(message) = response.message { println!("OK {}", message); } } Ok(()) } pub fn is_running() -> bool { resolve_socket_path(None) .map(|path| supervisor_running(&path)) .unwrap_or(false) } pub fn try_handle_daemon_action(action: &DaemonAction, config_path: Option<&Path>) -> Result<bool> { let socket_path = resolve_socket_path(None)?; if !supervisor_running(&socket_path) { if !ensure_supervisor_running(&socket_path, false, false).unwrap_or(false) { return Ok(false); } } let request = IpcRequest { action: match action { DaemonAction::Start { name } => SupervisorIpcAction::StartDaemon { name: name.clone(), config_path: config_path.map(|p| p.display().to_string()), }, DaemonAction::Stop { name } => SupervisorIpcAction::StopDaemon { name: name.clone(), config_path: config_path.map(|p| p.display().to_string()), }, DaemonAction::Restart { name } => SupervisorIpcAction::RestartDaemon { name: name.clone(), config_path: config_path.map(|p| p.display().to_string()), }, DaemonAction::Status { .. } => SupervisorIpcAction::Status { config_path: config_path.map(|p| p.display().to_string()), }, DaemonAction::List => SupervisorIpcAction::List { config_path: config_path.map(|p| p.display().to_string()), }, }, }; let response = match send_request(&socket_path, &request) { Ok(resp) => resp, Err(_) => return Ok(false), }; if !response.ok { if let Some(message) = response.message { eprintln!("WARN supervisor error: {}", message); } return Ok(false); } if let Some(daemons) = response.daemons { match action { DaemonAction::Status { name } => print_status_views(&daemons, name.as_deref()), DaemonAction::List => print_list_views(&daemons), _ => {} } return Ok(true); } if let Some(message) = response.message { println!("OK {}", message); } Ok(true) } pub fn resolve_socket_path(override_path: Option<&Path>) -> Result<PathBuf> { if let Some(path) = override_path { return Ok(config::expand_path(&path.to_string_lossy())); } let base = config::ensure_global_state_dir()?; Ok(base.join("supervisor.sock")) } fn supervisor_pid_path() -> Result<PathBuf> { let base = config::ensure_global_state_dir()?; Ok(base.join("supervisor.pid")) } fn supervisor_log_path() -> Result<PathBuf> { let base = config::ensure_global_state_dir()?; Ok(base.join("supervisor.log")) } fn supervisor_running(socket_path: &Path) -> bool { if !socket_path.exists() { return false; } let request = IpcRequest { action: SupervisorIpcAction::Ping, }; send_request(socket_path, &request) .map(|resp| resp.ok) .unwrap_or(false) } fn ensure_supervisor_running(socket_path: &Path, announce: bool, boot: bool) -> Result<bool> { if supervisor_running(socket_path) { if announce { println!("Supervisor already running."); } return Ok(true); } if let Some(started) = ensure_supervisor_via_launchd(socket_path, announce, boot)? { return Ok(started); } let exe = std::env::current_exe().context("failed to resolve flow binary")?; let mut cmd = Command::new(exe); cmd.arg("supervisor").arg("run"); cmd.arg("--socket").arg(socket_path); if boot { cmd.arg("--boot"); } cmd.stdin(Stdio::null()); let log_path = supervisor_log_path().ok(); if let Some(path) = &log_path { let log_file = fs::OpenOptions::new() .create(true) .append(true) .open(path) .ok(); if let Some(file) = log_file { let file_err = file.try_clone().ok(); cmd.stdout(file); if let Some(err) = file_err { cmd.stderr(err); } else { cmd.stderr(Stdio::null()); } } else { cmd.stdout(Stdio::null()).stderr(Stdio::null()); } } else { cmd.stdout(Stdio::null()).stderr(Stdio::null()); } #[cfg(unix)] { use std::os::unix::process::CommandExt; cmd.process_group(0); } let child = cmd.spawn().context("failed to start supervisor")?; persist_supervisor_pid(child.id())?; // Give the socket a moment to come up. let mut ready = false; for _ in 0..20 { std::thread::sleep(Duration::from_millis(150)); if supervisor_running(socket_path) { ready = true; break; } } if ready { if announce { println!("Supervisor started."); } return Ok(true); } if announce { if let Some(path) = log_path { eprintln!( "WARN supervisor did not respond after launch. Check {}", path.display() ); } else { eprintln!("WARN supervisor did not respond after launch."); } } Ok(false) } fn show_status(socket_path: &Path) -> Result<()> { if supervisor_running(socket_path) { println!("Supervisor running (socket: {}).", socket_path.display()); return Ok(()); } println!("Supervisor not running."); Ok(()) } fn stop_supervisor(socket_path: &Path) -> Result<()> { if let Ok(pid_path) = supervisor_pid_path() { if let Ok(Some(pid)) = load_supervisor_pid(&pid_path) { if running::process_alive(pid) { terminate_process(pid).ok(); } remove_supervisor_pid(&pid_path).ok(); } } if socket_path.exists() { fs::remove_file(socket_path).ok(); } println!("Supervisor stopped (if it was running)."); Ok(()) } fn run_server(socket_path: &Path, boot: bool) -> Result<()> { if let Some(parent) = socket_path.parent() { fs::create_dir_all(parent)?; } if socket_path.exists() { if supervisor_running(socket_path) { println!("Supervisor already running; exiting."); return Ok(()); } fs::remove_file(socket_path).ok(); } #[cfg(unix)] let listener = std::os::unix::net::UnixListener::bind(socket_path) .with_context(|| format!("failed to bind {}", socket_path.display()))?; #[cfg(not(unix))] { bail!("Supervisor IPC is only supported on unix platforms right now."); } let state = Arc::new(Mutex::new(SupervisorState::default())); let active_path = resolve_active_project_config_path(); bootstrap_daemons(&state, active_path.as_deref(), boot)?; let monitor_state = Arc::clone(&state); std::thread::spawn(move || { if let Err(err) = monitor_daemons(monitor_state) { eprintln!("WARN supervisor monitor failed: {err}"); } }); #[cfg(unix)] { for stream in listener.incoming() { match stream { Ok(stream) => { if let Err(err) = handle_client(stream, &state) { eprintln!("WARN supervisor request failed: {err}"); } } Err(err) => { eprintln!("WARN supervisor accept failed: {err}"); } } } } Ok(()) } fn ensure_supervisor_via_launchd( socket_path: &Path, announce: bool, boot: bool, ) -> Result<Option<bool>> { #[cfg(target_os = "macos")] { if !launch_agent_installed() { return Ok(None); } if boot { install_launch_agent(socket_path, true)?; } if announce { println!("Starting supervisor via launchd..."); } launch_agent_kickstart()?; let mut ready = false; for _ in 0..20 { std::thread::sleep(Duration::from_millis(150)); if supervisor_running(socket_path) { ready = true; break; } } if ready { if announce { println!("Supervisor started (launchd)."); } return Ok(Some(true)); } if announce { eprintln!("WARN launchd supervisor did not respond."); } return Ok(Some(false)); } #[cfg(not(target_os = "macos"))] { let _ = socket_path; let _ = announce; let _ = boot; Ok(None) } } #[cfg(target_os = "macos")] fn launch_agent_label() -> &'static str { "io.linsa.flow-supervisor" } #[cfg(target_os = "macos")] fn launch_agent_plist_path() -> Result<PathBuf> { let dir = config::expand_path("~/Library/LaunchAgents"); fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; Ok(dir.join(format!("{}.plist", launch_agent_label()))) } #[cfg(target_os = "macos")] fn launch_agent_installed() -> bool { launch_agent_plist_path() .map(|p| p.exists()) .unwrap_or(false) } #[cfg(target_os = "macos")] fn launch_agent_domain() -> String { let uid = unsafe { libc::getuid() }; format!("gui/{}", uid) } #[cfg(target_os = "macos")] fn launch_agent_target() -> String { format!("{}/{}", launch_agent_domain(), launch_agent_label()) } #[cfg(target_os = "macos")] fn launch_agent_program_args(socket_path: &Path, boot: bool) -> Result<Vec<String>> { let exe = std::env::current_exe().context("failed to resolve flow binary")?; let mut args = vec![ exe.to_string_lossy().into_owned(), "supervisor".to_string(), "run".to_string(), "--socket".to_string(), socket_path.to_string_lossy().into_owned(), ]; if boot { args.push("--boot".to_string()); } Ok(args) } #[cfg(target_os = "macos")] fn launch_agent_plist(socket_path: &Path, boot: bool, log_path: Option<&Path>) -> Result<String> { let args = launch_agent_program_args(socket_path, boot)?; let mut buf = String::new(); buf.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); buf.push_str( "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n", ); buf.push_str("<plist version=\"1.0\">\n<dict>\n"); buf.push_str(" <key>Label</key>\n"); buf.push_str(&format!(" <string>{}</string>\n", launch_agent_label())); buf.push_str(" <key>ProgramArguments</key>\n <array>\n"); for arg in args { buf.push_str(&format!(" <string>{}</string>\n", xml_escape(&arg))); } buf.push_str(" </array>\n"); buf.push_str(" <key>RunAtLoad</key>\n <true/>\n"); buf.push_str(" <key>KeepAlive</key>\n <dict>\n"); buf.push_str(" <key>SuccessfulExit</key>\n <false/>\n"); buf.push_str(" </dict>\n"); if let Some(path) = log_path { let log = path.to_string_lossy(); buf.push_str(" <key>StandardOutPath</key>\n"); buf.push_str(&format!( " <string>{}</string>\n", xml_escape(log.as_ref()) )); buf.push_str(" <key>StandardErrorPath</key>\n"); buf.push_str(&format!( " <string>{}</string>\n", xml_escape(log.as_ref()) )); } buf.push_str("</dict>\n</plist>\n"); Ok(buf) } #[cfg(target_os = "macos")] fn install_launch_agent(socket_path: &Path, boot: bool) -> Result<()> { let plist_path = launch_agent_plist_path()?; let log_path = supervisor_log_path().ok(); let plist = launch_agent_plist(socket_path, boot, log_path.as_deref())?; fs::write(&plist_path, plist) .with_context(|| format!("failed to write {}", plist_path.display()))?; let domain = launch_agent_domain(); let target = launch_agent_target(); let _ = Command::new("launchctl") .args(["bootout", &domain, plist_path.to_string_lossy().as_ref()]) .output(); let output = Command::new("launchctl") .args(["bootstrap", &domain, plist_path.to_string_lossy().as_ref()]) .output() .context("failed to bootstrap launch agent")?; if !output.status.success() { bail!( "launchctl bootstrap failed: {}", String::from_utf8_lossy(&output.stderr) ); } let _ = Command::new("launchctl").args(["enable", &target]).output(); let output = Command::new("launchctl") .args(["kickstart", "-k", &target]) .output() .context("failed to kickstart launch agent")?; if !output.status.success() { bail!( "launchctl kickstart failed: {}", String::from_utf8_lossy(&output.stderr) ); } println!( "Installed launch agent {} at {}", launch_agent_label(), plist_path.display() ); Ok(()) } #[cfg(target_os = "macos")] fn launch_agent_kickstart() -> Result<()> { let target = launch_agent_target(); let output = Command::new("launchctl") .args(["kickstart", "-k", &target]) .output() .context("failed to kickstart launch agent")?; if !output.status.success() { bail!( "launchctl kickstart failed: {}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) } #[cfg(target_os = "macos")] fn uninstall_launch_agent(_socket_path: &Path) -> Result<()> { let plist_path = launch_agent_plist_path()?; let domain = launch_agent_domain(); let _ = Command::new("launchctl") .args(["bootout", &domain, plist_path.to_string_lossy().as_ref()]) .output(); if plist_path.exists() { fs::remove_file(&plist_path) .with_context(|| format!("failed to remove {}", plist_path.display()))?; } println!("Removed launch agent {}", launch_agent_label()); Ok(()) } #[cfg(not(target_os = "macos"))] fn install_launch_agent(_socket_path: &Path, _boot: bool) -> Result<()> { bail!("launch agent install is only supported on macOS"); } #[cfg(not(target_os = "macos"))] fn uninstall_launch_agent(_socket_path: &Path) -> Result<()> { bail!("launch agent uninstall is only supported on macOS"); } #[cfg(target_os = "macos")] fn xml_escape(input: &str) -> String { let mut out = String::with_capacity(input.len()); for ch in input.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(ch), } } out } #[cfg(unix)] fn handle_client(stream: std::os::unix::net::UnixStream, state: &SharedState) -> Result<()> { let mut reader = BufReader::new(&stream); let mut line = Vec::with_capacity(512); reader.read_until(b'\n', &mut line)?; let trimmed = trim_ascii_whitespace(&line); if trimmed.is_empty() { return Ok(()); } let request: IpcRequest = serde_json::from_slice(trimmed)?; let response = handle_request(request, state)?; let mut writer = &stream; let payload = serde_json::to_string(&response)?; writer.write_all(payload.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; Ok(()) } fn handle_request(request: IpcRequest, state: &SharedState) -> Result<IpcResponse> { match request.action { SupervisorIpcAction::Ping => Ok(IpcResponse { ok: true, message: Some("pong".to_string()), daemons: None, }), SupervisorIpcAction::StartDaemon { name, config_path } => { let path = resolve_config_path(config_path.as_deref()); let daemon_cfg = resolve_daemon_config(&name, path.as_deref())?; daemon::start_daemon_with_path(&name, path.as_deref())?; register_managed_daemon(state, &daemon_cfg, path.as_deref(), false)?; Ok(IpcResponse { ok: true, message: Some(format!("{} started", name)), daemons: None, }) } SupervisorIpcAction::StopDaemon { name, config_path } => { let path = resolve_config_path(config_path.as_deref()); daemon::stop_daemon_with_path(&name, path.as_deref())?; disable_managed_daemon(state, &name, path.as_deref())?; Ok(IpcResponse { ok: true, message: Some(format!("{} stopped", name)), daemons: None, }) } SupervisorIpcAction::RestartDaemon { name, config_path } => { let path = resolve_config_path(config_path.as_deref()); let daemon_cfg = resolve_daemon_config(&name, path.as_deref())?; daemon::stop_daemon_with_path(&name, path.as_deref()).ok(); std::thread::sleep(Duration::from_millis(300)); daemon::start_daemon_with_path(&name, path.as_deref())?; register_managed_daemon(state, &daemon_cfg, path.as_deref(), false)?; Ok(IpcResponse { ok: true, message: Some(format!("{} restarted", name)), daemons: None, }) } SupervisorIpcAction::Status { config_path } => { let views = build_status_views(resolve_config_path(config_path.as_deref()).as_deref())?; Ok(IpcResponse { ok: true, message: None, daemons: Some(views), }) } SupervisorIpcAction::List { config_path } => { let views = build_status_views(resolve_config_path(config_path.as_deref()).as_deref())?; Ok(IpcResponse { ok: true, message: None, daemons: Some(views), }) } } } fn build_status_views(config_path: Option<&Path>) -> Result<Vec<DaemonStatusView>> { let config = daemon::load_merged_config_with_path(config_path)?; let mut views = Vec::new(); for daemon_cfg in config.daemons { let status = daemon::get_daemon_status(&daemon_cfg); let name = daemon_cfg.name.clone(); let description = daemon_cfg.description.clone(); let health_target = daemon_cfg.health_target_label(); views.push(DaemonStatusView { name, running: status.running, pid: status.pid, healthy: status.healthy, health_target, description, }); } Ok(views) } pub fn daemon_status_views(config_path: Option<&Path>) -> Result<Vec<DaemonStatusView>> { build_status_views(config_path) } fn resolve_config_path(config_path: Option<&str>) -> Option<PathBuf> { config_path.map(|path| config::expand_path(path)) } fn resolve_active_project_config_path() -> Option<PathBuf> { let name = projects::get_active_project()?; let entry = projects::resolve_project(&name).ok().flatten()?; Some(entry.config_path) } fn bootstrap_daemons( state: &SharedState, active_config_path: Option<&Path>, boot: bool, ) -> Result<()> { let mut seen = std::collections::HashSet::new(); let global_path = config::default_config_path(); if global_path.exists() { if let Ok(cfg) = config::load(&global_path) { let mut all_daemons = cfg.daemons; for server in &cfg.servers { all_daemons.push(server.to_daemon_config()); } start_daemon_set(state, all_daemons, None, boot, &mut seen)?; } } if let Some(path) = active_config_path { if path.exists() { if let Ok(cfg) = config::load(path) { let mut all_daemons = cfg.daemons; for server in &cfg.servers { all_daemons.push(server.to_daemon_config()); } start_daemon_set(state, all_daemons, Some(path), boot, &mut seen)?; } } } Ok(()) } fn start_daemon_set( state: &SharedState, daemons: Vec<config::DaemonConfig>, config_path: Option<&Path>, boot: bool, seen: &mut std::collections::HashSet<String>, ) -> Result<()> { for daemon_cfg in daemons { if !should_start_daemon(&daemon_cfg, boot) { continue; } let key = daemon_key(&daemon_cfg.name, config_path); if !seen.insert(key) { continue; } match daemon::start_daemon_with_path(&daemon_cfg.name, config_path) { Ok(()) => { register_managed_daemon(state, &daemon_cfg, config_path, false)?; } Err(err) => { eprintln!("WARN failed to autostart {}: {}", daemon_cfg.name, err); } } } Ok(()) } fn should_start_daemon(daemon_cfg: &config::DaemonConfig, boot: bool) -> bool { if daemon_cfg.autostart { return true; } if boot && daemon_cfg.boot { return true; } false } fn should_restart(entry: &ManagedDaemon) -> bool { match entry.restart { config::DaemonRestartPolicy::Never => false, config::DaemonRestartPolicy::Always => true, config::DaemonRestartPolicy::OnFailure => true, } } fn reconcile_removed(state: &SharedState, active_config_path: &Option<PathBuf>) { // Collect all expected daemon names from current config let mut expected: std::collections::HashSet<String> = std::collections::HashSet::new(); let global_path = config::default_config_path(); if global_path.exists() { if let Ok(cfg) = config::load(&global_path) { for d in &cfg.daemons { expected.insert(d.name.clone()); } for s in &cfg.servers { expected.insert(s.name.clone()); } } } if let Some(path) = active_config_path { if path.exists() { if let Ok(cfg) = config::load(path) { for d in &cfg.daemons { expected.insert(d.name.clone()); } for s in &cfg.servers { expected.insert(s.name.clone()); } } } } // Find managed entries not in expected set and stop them let managed: Vec<ManagedDaemon> = { let st = state.lock().expect("lock"); st.managed.values().cloned().collect() }; for entry in managed { if !expected.contains(&entry.name) { daemon::stop_daemon(&entry.name).ok(); let mut st = state.lock().expect("lock"); st.managed.remove(&entry.name); } } } fn monitor_daemons(state: SharedState) -> Result<()> { let mut last_active = resolve_active_project_config_path() .as_deref() .map(normalize_path); let global_path = config::default_config_path(); let mut last_global_mtime = std::fs::metadata(&global_path) .ok() .and_then(|m| m.modified().ok()); loop { std::thread::sleep(Duration::from_secs(2)); let now = Instant::now(); let active_path = resolve_active_project_config_path() .as_deref() .map(normalize_path); // Check if global config changed let current_global_mtime = std::fs::metadata(&global_path) .ok() .and_then(|m| m.modified().ok()); if current_global_mtime != last_global_mtime { last_global_mtime = current_global_mtime; bootstrap_daemons(&state, active_path.as_deref(), false).ok(); reconcile_removed(&state, &active_path); } if active_path != last_active { bootstrap_daemons(&state, active_path.as_deref(), false).ok(); reconcile_removed(&state, &active_path); last_active = active_path.clone(); } let entries: Vec<ManagedDaemon> = { let state = state.lock().expect("supervisor state lock"); state.managed.values().cloned().collect() }; let mut to_restart: Vec<(String, Option<PathBuf>)> = Vec::new(); let mut to_stop: Vec<(String, Option<PathBuf>)> = Vec::new(); let mut updates: Vec<(String, Option<u32>, bool, u32, u32, Option<Instant>)> = Vec::new(); for entry in entries { if entry.disabled { continue; } if entry.autostop { if let Some(ref path) = entry.config_path { if !active_path_matches(&active_path, path) { to_stop.push((entry.name.clone(), entry.config_path.clone())); updates.push(( daemon_key(&entry.name, entry.config_path.as_deref()), entry.retry_remaining, true, entry.health_failures, entry.restart_attempts, entry.next_restart_at, )); continue; } } } let config_path = entry.config_path.clone(); let daemon_cfg = match resolve_daemon_config(&entry.name, config_path.as_deref()) { Ok(cfg) => cfg, Err(err) => { eprintln!( "WARN supervisor missing daemon config for {}: {}", entry.name, err ); continue; } }; let status = daemon::get_daemon_status(&daemon_cfg); if status.running { if status.healthy == Some(false) { let key = daemon_key(&entry.name, config_path.as_deref()); let failures = entry.health_failures.saturating_add(1); let should_restart_for_health = failures >= 3; if should_restart_for_health && should_restart(&entry) { if entry .next_restart_at .map(|deadline| now < deadline) .unwrap_or(false) { updates.push(( key, entry.retry_remaining, false, failures, entry.restart_attempts, entry.next_restart_at, )); continue; } let delay_secs = 2u64 .saturating_pow(entry.restart_attempts.saturating_add(1)) .min(60); let next_restart_at = Some(now + Duration::from_secs(delay_secs)); updates.push(( key, entry.retry_remaining, false, failures, entry.restart_attempts.saturating_add(1), next_restart_at, )); to_restart.push((entry.name.clone(), config_path)); } else { updates.push(( key, entry.retry_remaining, false, failures, entry.restart_attempts, entry.next_restart_at, )); } } else if entry.health_failures != 0 || entry.restart_attempts != 0 { let key = daemon_key(&entry.name, config_path.as_deref()); updates.push((key, entry.retry_remaining, false, 0, 0, None)); } continue; } let key = daemon_key(&entry.name, config_path.as_deref()); if !should_restart(&entry) { continue; } if entry .next_restart_at .map(|deadline| now < deadline) .unwrap_or(false) { updates.push(( key, entry.retry_remaining, false, entry.health_failures, entry.restart_attempts, entry.next_restart_at, )); continue; } let mut retry_remaining = entry.retry_remaining; if entry.restart != config::DaemonRestartPolicy::Always { if let Some(remaining) = retry_remaining { if remaining == 0 { continue; } retry_remaining = Some(remaining.saturating_sub(1)); } } let delay_secs = 2u64 .saturating_pow(entry.restart_attempts.saturating_add(1)) .min(60); updates.push(( key, retry_remaining, false, entry.health_failures.saturating_add(1), entry.restart_attempts.saturating_add(1), Some(now + Duration::from_secs(delay_secs)), )); to_restart.push((entry.name.clone(), config_path)); } if !updates.is_empty() { let mut state = state.lock().expect("supervisor state lock"); for ( key, retry_remaining, disabled, health_failures, restart_attempts, next_restart_at, ) in updates { if let Some(entry) = state.managed.get_mut(&key) { entry.retry_remaining = retry_remaining; entry.disabled = disabled; entry.health_failures = health_failures; entry.restart_attempts = restart_attempts; entry.next_restart_at = next_restart_at; } } } for (name, config_path) in to_stop { daemon::stop_daemon_with_path(&name, config_path.as_deref()).ok(); } for (name, config_path) in to_restart { daemon::start_daemon_with_path(&name, config_path.as_deref()).ok(); } } } fn active_path_matches(active: &Option<PathBuf>, candidate: &Path) -> bool { match active { Some(active_path) => active_path == &normalize_path(candidate), None => false, } } fn normalize_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } fn resolve_daemon_config(name: &str, config_path: Option<&Path>) -> Result<config::DaemonConfig> { let cfg = daemon::load_merged_config_with_path(config_path)?; cfg.daemons .into_iter() .find(|daemon| daemon.name == name) .ok_or_else(|| anyhow::anyhow!("daemon '{}' not found in config", name)) } fn register_managed_daemon( state: &SharedState, daemon_cfg: &config::DaemonConfig, config_path: Option<&Path>, disabled: bool, ) -> Result<()> { let mut state = state.lock().expect("supervisor state lock"); let key = daemon_key(&daemon_cfg.name, config_path); let entry = ManagedDaemon { name: daemon_cfg.name.clone(), config_path: config_path.map(|path| path.to_path_buf()), restart: daemon::restart_policy_for(daemon_cfg), retry_remaining: daemon_cfg.retry, autostop: daemon_cfg.autostop, disabled, health_failures: 0, restart_attempts: 0, next_restart_at: None, }; state.managed.insert(key, entry); Ok(()) } fn disable_managed_daemon( state: &SharedState, name: &str, config_path: Option<&Path>, ) -> Result<()> { let mut state = state.lock().expect("supervisor state lock"); let key = daemon_key(name, config_path); if let Some(entry) = state.managed.get_mut(&key) { entry.disabled = true; return Ok(()); } if let Ok(cfg) = resolve_daemon_config(name, config_path) { let entry = ManagedDaemon { name: cfg.name.clone(), config_path: config_path.map(|path| path.to_path_buf()), restart: daemon::restart_policy_for(&cfg), retry_remaining: cfg.retry, autostop: cfg.autostop, disabled: true, health_failures: 0, restart_attempts: 0, next_restart_at: None, }; state.managed.insert(key, entry); } Ok(()) } fn daemon_key(name: &str, config_path: Option<&Path>) -> String { match config_path { Some(path) => format!("{}::{}", name, path.display()), None => name.to_string(), } } fn print_status_views(daemons: &[DaemonStatusView], filter: Option<&str>) { if daemons.is_empty() { println!("No daemons configured."); return; } let mut matched = false; println!("Daemon Status:"); println!(); for daemon in daemons { if let Some(name) = filter { if daemon.name != name { continue; } } matched = true; let icon = if daemon.running { if daemon.healthy == Some(false) { "WARN" } else { "OK" } } else { "NO" }; let state = if daemon.running { "running" } else { "stopped" }; print!(" {} {}: {}", icon, daemon.name, state); if let Some(target) = &daemon.health_target { if daemon.running { if daemon.healthy == Some(false) { print!(" (unhealthy: {})", target); } else { print!(" ({})", target); } } } if let Some(pid) = daemon.pid { print!(" [PID {}]", pid); } println!(); if let Some(desc) = &daemon.description { println!(" {}", desc); } } if let Some(name) = filter { if !matched { println!("Daemon '{}' not found.", name); } } } fn print_list_views(daemons: &[DaemonStatusView]) { if daemons.is_empty() { println!("No daemons configured."); return; } println!("Available daemons:"); for daemon in daemons { print!(" {}", daemon.name); if let Some(desc) = &daemon.description { print!(" - {}", desc); } println!(); } } #[inline] fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] { let mut start = 0usize; let mut end = bytes.len(); while start < end && bytes[start].is_ascii_whitespace() { start += 1; } while end > start && bytes[end - 1].is_ascii_whitespace() { end -= 1; } &bytes[start..end] } pub fn send_request(socket_path: &Path, request: &IpcRequest) -> Result<IpcResponse> { #[cfg(unix)] { let mut stream = std::os::unix::net::UnixStream::connect(socket_path)?; let payload = serde_json::to_string(request)?; stream.write_all(payload.as_bytes())?; stream.write_all(b"\n")?; stream.flush()?; let mut reader = BufReader::new(stream); let mut line = Vec::with_capacity(512); reader.read_until(b'\n', &mut line)?; let trimmed = trim_ascii_whitespace(&line); let response: IpcResponse = serde_json::from_slice(trimmed)?; Ok(response) } #[cfg(not(unix))] { let _ = socket_path; let _ = request; bail!("Supervisor IPC is only supported on unix platforms right now."); } } fn persist_supervisor_pid(pid: u32) -> Result<()> { let path = supervisor_pid_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(&path, pid.to_string()) .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } fn load_supervisor_pid(path: &Path) -> Result<Option<u32>> { if !path.exists() { return Ok(None); } let contents = fs::read_to_string(path)?; let pid: u32 = contents.trim().parse().ok().unwrap_or(0); if pid == 0 { Ok(None) } else { Ok(Some(pid)) } } fn remove_supervisor_pid(path: &Path) -> Result<()> { if path.exists() { fs::remove_file(path).ok(); } Ok(()) } fn terminate_process(pid: u32) -> Result<()> { let status = Command::new("kill").arg("-9").arg(pid.to_string()).status(); if let Ok(status) = status { if status.success() { return Ok(()); } } bail!("failed to stop supervisor process {}", pid) } ================================================ FILE: src/sync.rs ================================================ //! Git sync command - comprehensive repo synchronization. //! //! Provides a single command to sync a git repository: //! - Pull from tracking/default remote (with rebase) //! - Sync upstream if configured (fetch, merge) //! - Push to configured git remote (default: origin) use std::env; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Instant; use std::{cell::RefCell, collections::HashMap, collections::HashSet}; use anyhow::{Context, Result, bail}; use chrono::Utc; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, terminal, }; use serde::{Deserialize, Serialize}; use crate::ai_context; use crate::cli::{CheckoutCommand, SwitchCommand, SyncCommand}; use crate::commit; use crate::config; use crate::git_guard; use crate::push; use crate::secret_redact; use crate::todo; #[derive(Serialize, Clone)] struct SyncEvent { at: String, stage: String, message: String, } #[derive(Serialize)] struct SyncSnapshot { timestamp: String, duration_ms: u128, repo_root: String, repo_name: String, branch_before: String, branch_after: String, head_before: String, head_after: String, upstream_before: Option<String>, upstream_after: Option<String>, origin_url: Option<String>, upstream_url: Option<String>, status_before: String, status_after: String, stashed: bool, rebase: bool, pushed: bool, success: bool, error: Option<String>, remote_updates: Vec<SyncRemoteUpdate>, events: Vec<SyncEvent>, } #[derive(Serialize, Clone)] struct SyncRemoteUpdate { remote: String, branch: String, before_tip: Option<String>, after_tip: String, commit_count: usize, commits: Vec<String>, } struct SyncRecorder { enabled: bool, started_at: Instant, repo_root: String, repo_name: String, branch_before: String, head_before: String, upstream_before: Option<String>, origin_url: Option<String>, upstream_url: Option<String>, status_before: String, events: Vec<SyncEvent>, stashed: bool, rebase: bool, pushed: bool, remote_updates: Vec<SyncRemoteUpdate>, } // Use the Claude family alias so sync always targets the latest Opus model. const SYNC_CLAUDE_MODEL: &str = "opus"; fn sync_should_push(cmd: &SyncCommand) -> bool { cmd.push && !cmd.no_push } fn sync_claude_command(prompt: &str) -> Command { let mut cmd = Command::new("claude"); cmd.args([ "--print", "--model", SYNC_CLAUDE_MODEL, "--dangerously-skip-permissions", prompt, ]); cmd } impl SyncRecorder { fn new(cmd: &SyncCommand) -> Result<Self> { let should_push = sync_should_push(cmd); let repo_root = git_capture(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|_| ".".to_string()); let repo_root = repo_root.trim().to_string(); let repo_name = std::path::Path::new(&repo_root) .file_name() .and_then(|s| s.to_str()) .unwrap_or("repo") .to_string(); let branch_before = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_default() .trim() .to_string(); let head_before = git_capture(&["rev-parse", "HEAD"]) .unwrap_or_default() .trim() .to_string(); let upstream_before = git_capture(&["rev-parse", "--abbrev-ref", "@{upstream}"]) .ok() .map(|s| s.trim().to_string()); let origin_url = git_capture(&["remote", "get-url", "origin"]) .ok() .map(|s| s.trim().to_string()); let upstream_url = git_capture(&["remote", "get-url", "upstream"]) .ok() .map(|s| s.trim().to_string()); let status_before = git_capture(&["status", "--porcelain"]).unwrap_or_default(); let mut recorder = SyncRecorder { enabled: true, started_at: Instant::now(), repo_root, repo_name, branch_before, head_before, upstream_before, origin_url, upstream_url, status_before, events: Vec::new(), stashed: false, rebase: cmd.rebase, pushed: should_push, remote_updates: Vec::new(), }; recorder.record( "start", format!( "sync start (rebase={}, stash={}, push={})", cmd.rebase, cmd.stash, should_push ), ); Ok(recorder) } fn disabled() -> Self { SyncRecorder { enabled: false, started_at: Instant::now(), repo_root: String::new(), repo_name: String::new(), branch_before: String::new(), head_before: String::new(), upstream_before: None, origin_url: None, upstream_url: None, status_before: String::new(), events: Vec::new(), stashed: false, rebase: false, pushed: false, remote_updates: Vec::new(), } } fn record(&mut self, stage: &str, message: impl Into<String>) { if !self.enabled { return; } self.events.push(SyncEvent { at: Utc::now().to_rfc3339(), stage: stage.to_string(), message: message.into(), }); } fn set_stashed(&mut self, stashed: bool) { self.stashed = stashed; } fn add_remote_update(&mut self, update: SyncRemoteUpdate) { if !self.enabled { return; } if let Some(existing) = self .remote_updates .iter_mut() .find(|item| item.remote == update.remote && item.branch == update.branch) { *existing = update; } else { self.remote_updates.push(update); } } fn finish(&mut self, error: Option<&anyhow::Error>) { if !self.enabled { return; } let branch_after = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_default() .trim() .to_string(); let head_after = git_capture(&["rev-parse", "HEAD"]) .unwrap_or_default() .trim() .to_string(); let upstream_after = git_capture(&["rev-parse", "--abbrev-ref", "@{upstream}"]) .ok() .map(|s| s.trim().to_string()); let status_after = git_capture(&["status", "--porcelain"]).unwrap_or_default(); let snapshot = SyncSnapshot { timestamp: Utc::now().to_rfc3339(), duration_ms: self.started_at.elapsed().as_millis(), repo_root: self.repo_root.clone(), repo_name: self.repo_name.clone(), branch_before: self.branch_before.clone(), branch_after, head_before: self.head_before.clone(), head_after, upstream_before: self.upstream_before.clone(), upstream_after, origin_url: self.origin_url.clone(), upstream_url: self.upstream_url.clone(), status_before: self.status_before.clone(), status_after, stashed: self.stashed, rebase: self.rebase, pushed: self.pushed, success: error.is_none(), error: error.map(|e| e.to_string()), remote_updates: self.remote_updates.clone(), events: self.events.clone(), }; if let Err(err) = write_sync_snapshot(&snapshot) { eprintln!("warn: failed to write sync snapshot: {err}"); } } } /// Check the review-todo push gate. Returns `true` if push should proceed. /// Only P1+P2 items trigger the gate; P3/P4 are non-blocking. /// Reads `[commit].review-push-gate` from config: "warn" (default) | "block" | "off". /// `--allow-review-issues` overrides any mode. /// Fails open if todos can't be loaded. fn check_review_todo_push_gate( repo_root: &Path, allow_review_issues: bool, recorder: &mut SyncRecorder, ) -> bool { if allow_review_issues { return true; } let (p1, p2, _p3, _p4, _total) = match todo::count_open_review_todos_by_priority(repo_root) { Ok(counts) => counts, Err(_) => return true, // fail open }; let blocking = p1 + p2; if blocking == 0 { return true; } // Read gate mode from config let config_path = repo_root.join("flow.toml"); let gate_mode = if config_path.exists() { config::load(&config_path) .ok() .and_then(|cfg| cfg.commit) .and_then(|c| c.review_push_gate) .unwrap_or_else(|| "warn".to_string()) } else { "warn".to_string() }; match gate_mode.as_str() { "off" => true, "block" => { eprintln!( "✗ Push blocked: {} open review todos (P1:{}, P2:{})", blocking, p1, p2 ); eprintln!( " Resolve with `f reviews-todo list` or use --allow-review-issues to override." ); recorder.record("review-gate", format!("blocked (P1:{}, P2:{})", p1, p2)); false } _ => { // "warn" (default) eprintln!( "⚠ {} open review todos (P1:{}, P2:{}) — consider reviewing before push", blocking, p1, p2 ); eprintln!(" Run `f reviews-todo list` to see details."); recorder.record("review-gate", format!("warned (P1:{}, P2:{})", p1, p2)); true } } } /// Run the sync command. pub fn run(cmd: SyncCommand) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); // Check we're in a git repo if git_capture(&["rev-parse", "--git-dir"]).is_err() { bail!("Not a git repository"); } let mut recorder = SyncRecorder::new(&cmd).unwrap_or_else(|err| { eprintln!("warn: unable to init sync recorder: {err}"); SyncRecorder::disabled() }); let result = (|| -> Result<()> { // Determine if auto-fix is enabled (--fix is default, --no-fix disables) let auto_fix = cmd.fix && !cmd.no_fix; let should_push = sync_should_push(&cmd); let repo_root = git_capture(&["rev-parse", "--show-toplevel"]) .unwrap_or_else(|_| ".".to_string()) .trim() .to_string(); let repo_root_path = Path::new(&repo_root); let preferred_remote = config::preferred_git_remote_for_repo(repo_root_path); let mut use_jj = should_use_jj(repo_root_path); let mut jj_disabled_by_custom_tracking = false; if use_jj && preferred_remote != "origin" && preferred_remote != "upstream" { println!( "⚠️ Configured git.remote '{}' detected; using git sync flow.", preferred_remote ); recorder.record( "mode", format!("jj bypassed (configured git.remote {})", preferred_remote), ); use_jj = false; } if use_jj { if let Ok(branch) = git_capture_in(repo_root_path, &["rev-parse", "--abbrev-ref", "HEAD"]) { let branch = branch.trim(); if branch != "HEAD" { if let Some((remote, _)) = resolve_tracking_remote_branch_in(repo_root_path, Some(branch)) { if remote != "origin" && remote != "upstream" { println!( "⚠️ Tracking remote '{}' detected; using git sync flow for reliable branch + upstream sync.", remote ); recorder.record( "mode", format!("jj bypassed (custom tracking remote {})", remote), ); use_jj = false; jj_disabled_by_custom_tracking = true; } } } } } if repo_root_path.join(".jj").exists() && !use_jj && !jj_disabled_by_custom_tracking { println!("⚠️ jj workspace appears unhealthy; falling back to git sync flow."); println!( " Fix: `jj git import` (or if still broken: `rm -rf .jj && jj git init --colocate`)" ); recorder.record("mode", "jj unavailable/unhealthy; fallback to git"); } let current_branch_for_queue = resolve_sync_branch_for_queue_guard(repo_root_path); let queue_present = match current_branch_for_queue.as_deref() { Some(branch) => commit::commit_queue_has_entries_on_branch(repo_root_path, branch), None => commit::commit_queue_has_entries_reachable_from_head(repo_root_path), }; if queue_present && !cmd.allow_queue && (cmd.rebase || use_jj) { recorder.record("queue", "blocked (commit queue present)"); bail!( "Commit queue is not empty. Rebase-based sync can rewrite commit SHAs.\n\ Use `f commit-queue list` to review, or re-run with `--allow-queue`." ); } if use_jj { recorder.record("mode", "using jj sync flow"); match run_jj_sync(repo_root_path, &cmd, auto_fix, &mut recorder) { Ok(()) => return Ok(()), Err(err) if is_jj_corruption_error(&err) => { println!("⚠️ jj sync failed due workspace/store issues; retrying with git."); println!( " Fix: `jj git import` (or if still broken: `rm -rf .jj && jj git init --colocate`)" ); recorder.record("mode", "jj failed (corruption); fallback to git"); } Err(err) => return Err(err), } } // Check for unmerged files (can exist even without active merge/rebase) let unmerged = git_capture(&["diff", "--name-only", "--diff-filter=U"]).unwrap_or_default(); if !unmerged.trim().is_empty() { let unmerged_files: Vec<&str> = unmerged.lines().filter(|l| !l.is_empty()).collect(); println!( "==> Found {} unmerged files, resolving...", unmerged_files.len() ); recorder.record( "unmerged", format!("found {} unmerged files", unmerged_files.len()), ); let should_fix = auto_fix || prompt_for_auto_fix()?; if should_fix { if try_resolve_conflicts()? { let _ = git_run(&["add", "-A"]); // Check if we're in a merge if is_merge_in_progress() { let _ = Command::new("git").args(["commit", "--no-edit"]).output(); } println!(" ✓ Unmerged files resolved"); } else { // Couldn't resolve - reset the conflicted files to HEAD println!(" Could not auto-resolve. Resetting unmerged files..."); for file in &unmerged_files { let _ = Command::new("git") .args(["checkout", "HEAD", "--", file]) .output(); } if is_merge_in_progress() { let _ = Command::new("git").args(["merge", "--abort"]).output(); } } } else { // User declined - reset the files println!(" Resetting unmerged files..."); for file in &unmerged_files { let _ = Command::new("git") .args(["checkout", "HEAD", "--", file]) .output(); } if is_merge_in_progress() { let _ = Command::new("git").args(["merge", "--abort"]).output(); } } } // Check for in-progress rebase/merge and handle it if is_rebase_in_progress() { println!("==> Rebase in progress, attempting to resolve..."); let should_fix = auto_fix || prompt_for_rebase_action()?; if should_fix { if try_resolve_rebase_conflicts()? { println!(" ✓ Rebase completed"); } else { println!(" Could not auto-resolve. Aborting rebase..."); let _ = Command::new("git").args(["rebase", "--abort"]).output(); } } else { println!(" Aborting rebase..."); let _ = Command::new("git").args(["rebase", "--abort"]).output(); } } // Check for in-progress merge if is_merge_in_progress() { println!("==> Merge in progress, attempting to resolve..."); let should_fix = auto_fix || prompt_for_auto_fix()?; if should_fix { if try_resolve_conflicts()? { let _ = git_run(&["add", "-A"]); let _ = Command::new("git").args(["commit", "--no-edit"]).output(); println!(" ✓ Merge completed"); } else { println!(" Could not auto-resolve. Aborting merge..."); let _ = Command::new("git").args(["merge", "--abort"]).output(); } } else { println!(" Aborting merge..."); let _ = Command::new("git").args(["merge", "--abort"]).output(); } } let current = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"])?; let current = current.trim(); // Check for uncommitted changes let status = git_capture(&["status", "--porcelain"])?; let has_changes = !status.trim().is_empty(); if has_changes && !cmd.stash { println!("You have uncommitted changes. Use --stash to auto-stash them."); recorder.record("stash", "skipped (uncommitted changes without --stash)"); bail!("Uncommitted changes"); } // Stash if needed let mut stashed = false; if has_changes && cmd.stash { println!("==> Stashing local changes..."); let stash_count_before = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); if let Err(err) = git_run(&["stash", "push", "-u", "-m", "f sync auto-stash"]) { recorder.record("stash", format!("stash failed: {}", err)); bail!( "Failed to stash local changes: {}. Resolve the issue and re-run sync.", err ); } let stash_count_after = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); stashed = stash_count_after > stash_count_before; } recorder.set_stashed(stashed); if has_changes && cmd.stash { recorder.record("stash", format!("stashed={}", stashed)); } // Resolve remotes for sync. let push_remote = preferred_remote.clone(); let has_push_remote = git_capture(&["remote", "get-url", &push_remote]).is_ok(); // Keep explicit origin/upstream detection for fork-sync heuristics. let has_origin = git_capture(&["remote", "get-url", "origin"]).is_ok(); let has_upstream = git_capture(&["remote", "get-url", "upstream"]).is_ok(); // Check if remotes are reachable (repo exists on remote) let push_remote_reachable = has_push_remote && git_capture(&["ls-remote", "--exit-code", "-q", &push_remote]).is_ok(); let origin_reachable = has_origin && git_capture(&["ls-remote", "--exit-code", "-q", "origin"]).is_ok(); // Step 1: Pull from tracking branch. let mut tracking = resolve_tracking_remote_branch_in(repo_root_path, Some(current)); if current != "HEAD" && has_push_remote { let should_retarget = tracking .as_ref() .map(|(remote, _)| remote != &push_remote) .unwrap_or(true); if should_retarget && remote_branch_exists(repo_root_path, &push_remote, current) { let branch_remote_key = format!("branch.{}.remote", current); let branch_merge_key = format!("branch.{}.merge", current); let merge_ref = format!("refs/heads/{}", current); let _ = git_run_in( repo_root_path, &["config", "--local", &branch_remote_key, &push_remote], ); let _ = git_run_in( repo_root_path, &["config", "--local", &branch_merge_key, &merge_ref], ); tracking = Some((push_remote.clone(), current.to_string())); } } if let Some((tracking_remote, tracking_branch)) = tracking { let tracking_reachable = git_capture_in( repo_root_path, &["ls-remote", "--exit-code", "-q", &tracking_remote], ) .is_ok(); if tracking_reachable { println!( "==> Pulling from {}/{}...", tracking_remote, tracking_branch ); recorder.record( "pull", format!( "pulling from {}/{} (rebase={})", tracking_remote, tracking_branch, cmd.rebase ), ); if cmd.rebase { let pull = Command::new("git") .current_dir(repo_root_path) .args([ "pull", "--rebase", tracking_remote.as_str(), tracking_branch.as_str(), ]) .output() .context("failed to run git pull --rebase")?; if !pull.status.success() { let pull_stdout = String::from_utf8_lossy(&pull.stdout); let pull_stderr = String::from_utf8_lossy(&pull.stderr); let pull_text = format!("{}\n{}", pull_stdout, pull_stderr).to_lowercase(); if pull_text.contains("cannot rebase: you have unstaged changes") || pull_text .contains("cannot pull with rebase: you have unstaged changes") { let _ = Command::new("git") .current_dir(repo_root_path) .args(["rebase", "--abort"]) .output(); restore_stash(repo_root_path, stashed); recorder.record("pull", "blocked by unstaged changes"); bail!( "git pull --rebase refused due unstaged changes. \ Clean local file conflicts/case-only path conflicts, then re-run `f sync`." ); } // Check if we're in a rebase conflict if is_rebase_in_progress() { let should_fix = auto_fix || prompt_for_auto_fix()?; if should_fix { if try_resolve_rebase_conflicts()? { println!(" ✓ Rebase conflicts auto-resolved"); recorder.record("pull", "rebase conflicts auto-resolved"); } else { restore_stash(repo_root_path, stashed); recorder.record("pull", "rebase conflicts unresolved"); bail!( "Rebase conflicts. Resolve manually:\n git status\n # fix conflicts\n git add . && git rebase --continue" ); } } else { restore_stash(repo_root_path, stashed); recorder.record("pull", "rebase conflicts unresolved"); bail!( "Rebase conflicts. Resolve manually:\n git status\n # fix conflicts\n git add . && git rebase --continue" ); } } else { restore_stash(repo_root_path, stashed); recorder.record("pull", "git pull --rebase failed"); bail!("git pull --rebase failed"); } } } else { if let Err(_) = git_run_in( repo_root_path, &[ "pull", "--no-rebase", "--no-edit", tracking_remote.as_str(), tracking_branch.as_str(), ], ) { // Check for merge conflicts let conflicts = git_capture(&["diff", "--name-only", "--diff-filter=U"]) .unwrap_or_default(); if !conflicts.trim().is_empty() { let should_fix = auto_fix || prompt_for_auto_fix()?; if should_fix { if try_resolve_conflicts()? { let _ = git_run(&["add", "-A"]); let _ = Command::new("git").args(["commit", "--no-edit"]).output(); println!(" ✓ Merge conflicts auto-resolved"); recorder.record("pull", "merge conflicts auto-resolved"); } else { restore_stash(repo_root_path, stashed); recorder.record("pull", "merge conflicts unresolved"); bail!( "Merge conflicts. Resolve manually:\n git status\n # fix conflicts\n git add . && git commit" ); } } else { restore_stash(repo_root_path, stashed); recorder.record("pull", "merge conflicts unresolved"); bail!( "Merge conflicts. Resolve manually:\n git status\n # fix conflicts\n git add . && git commit" ); } } else { restore_stash(repo_root_path, stashed); recorder.record("pull", "git pull failed"); bail!("git pull failed"); } } } recorder.record("pull", "pull complete"); } else { println!( "==> Tracking remote '{}' unreachable, skipping pull", tracking_remote ); recorder.record( "pull", format!("skipped (tracking remote unreachable: {})", tracking_remote), ); } } else { println!("==> No tracking branch, skipping pull"); recorder.record("pull", "skipped (no tracking branch)"); } // Step 2: Sync upstream if it exists. If no upstream remote is configured and we're on // a feature branch, fall back to syncing from origin's default branch. if has_upstream { println!("==> Syncing upstream..."); recorder.record("upstream", "syncing upstream"); if let Err(e) = sync_upstream_internal(repo_root_path, current, auto_fix, &mut recorder) { restore_stash(repo_root_path, stashed); return Err(e); } } else if has_origin && origin_reachable { if let Some(default_branch) = origin_default_branch_for_feature_sync(repo_root_path, current) { println!("==> Syncing origin/{} into {}...", default_branch, current); recorder.record( "upstream", format!("syncing origin/{} into {}", default_branch, current), ); if let Err(e) = sync_origin_default_internal( repo_root_path, current, &default_branch, auto_fix, &mut recorder, ) { restore_stash(repo_root_path, stashed); return Err(e); } } else { recorder.record("upstream", "skipped (no upstream remote)"); } } else { recorder.record("upstream", "skipped (no upstream remote)"); } // Step 3: Push to configured remote (defaults to origin). // Fork push override: redirect to private fork remote if configured. let (mut push_remote, mut has_push_remote, mut push_remote_reachable) = (push_remote, has_push_remote, push_remote_reachable); if should_push { if let Some((fork_remote, fork_owner, fork_repo)) = resolve_fork_push_target(repo_root_path) { let target_url = push::build_github_ssh_url(&fork_owner, &fork_repo); if let Err(e) = push::ensure_remote_points_to_target( repo_root_path, &fork_remote, &target_url, None, true, ) { eprintln!("Warning: could not set up fork remote: {}", e); } else { push::ensure_github_repo_exists(&fork_owner, &fork_repo).ok(); println!( "==> Fork push enabled: {}/{} (remote: {})", fork_owner, fork_repo, fork_remote ); push_remote = fork_remote; has_push_remote = true; push_remote_reachable = true; } } } // Review-todo push gate (git sync path) if should_push && !check_review_todo_push_gate(repo_root_path, cmd.allow_review_issues, &mut recorder) { recorder.record("push", "blocked by review-todo gate"); bail!("Push blocked by open review todos. Use --allow-review-issues to override."); } if has_push_remote && should_push { // Check if push remote == upstream (read-only clone, no fork) let push_remote_url = git_capture(&["remote", "get-url", &push_remote]).unwrap_or_default(); let upstream_url = git_capture(&["remote", "get-url", "upstream"]).unwrap_or_default(); let is_read_only = has_upstream && normalize_git_url(&push_remote_url) == normalize_git_url(&upstream_url); if is_read_only { println!( "==> Skipping push (remote '{}' == upstream, read-only clone)", push_remote ); println!(" To push, create a fork first: gh repo fork --remote"); recorder.record("push", "skipped (push remote == upstream)"); } else if !push_remote_reachable { // Remote repo doesn't exist or is unreachable. if cmd.create_repo && push_remote == "origin" { println!("==> Creating origin repo..."); if try_create_origin_repo()? { println!("==> Pushing to {}...", push_remote); git_run(&["push", "-u", &push_remote, current])?; recorder.record( "push", format!("created repo and pushed to {}", push_remote), ); } else { println!(" Could not create repo, skipping push"); recorder.record("push", "skipped (create repo failed)"); } } else { println!("==> Remote '{}' unreachable, skipping push", push_remote); println!(" The remote may be missing, private, or auth/network failed."); if push_remote == "origin" { println!(" Use --create-repo if origin does not exist yet."); } else { println!( " Create/fix remote '{}' and re-run sync (or set [git].remote).", push_remote ); } recorder.record( "push", format!("skipped (remote unreachable: {})", push_remote), ); } } else { println!("==> Pushing to {}...", push_remote); let push_result = push_with_autofix(current, &push_remote, auto_fix, cmd.max_fix_attempts); if let Err(e) = push_result { restore_stash(repo_root_path, stashed); recorder.record("push", "push failed"); return Err(e); } recorder.record("push", "push complete"); } } else if cmd.no_push { recorder.record("push", "skipped (--no-push)"); } else if has_push_remote { recorder.record("push", "skipped (default; use --push)"); } else { recorder.record("push", format!("skipped (missing remote: {})", push_remote)); } // Restore stash restore_stash(repo_root_path, stashed); if stashed { recorder.record("stash", "stash restored"); } // Explain new commits if configured let head_after_sha = git_capture(&["rev-parse", "HEAD"]) .unwrap_or_default() .trim() .to_string(); if recorder.head_before != head_after_sha { if let Err(e) = crate::explain_commits::maybe_run_after_sync(repo_root_path, &recorder.head_before) { eprintln!("warn: commit explanation failed: {e}"); } } println!("\n✓ Sync complete!"); recorder.record("complete", "sync complete"); Ok(()) })(); if result.is_ok() { let synced_commits = build_synced_commit_list(&recorder); if !synced_commits.is_empty() { println!("\n==> Synced commits:"); for line in &synced_commits { println!(" {}", line); } let payload = synced_commits.join("\n"); match copy_sync_output_to_clipboard(&payload) { Ok(true) => println!("Copied synced commit list to clipboard."), Ok(false) => {} Err(err) => { eprintln!("warn: failed to copy synced commit list to clipboard: {err}") } } } } recorder.finish(result.as_ref().err()); result } /// Switch to a branch and align upstream/jj state for flow workflows. pub fn run_switch(cmd: SwitchCommand) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); if git_capture(&["rev-parse", "--git-dir"]).is_err() { bail!("Not a git repository"); } let target_input = cmd.branch.trim(); if target_input.is_empty() { bail!("Branch name cannot be empty"); } let repo_root = git_capture(&["rev-parse", "--show-toplevel"])? .trim() .to_string(); let repo_root_path = PathBuf::from(repo_root); let resolution = resolve_switch_target(&repo_root_path, target_input)?; let target_branch = resolution.branch; git_guard::ensure_clean_for_push(&repo_root_path)?; let stash_enabled = cmd.stash && !cmd.no_stash; let has_changes = !git_capture_in(&repo_root_path, &["status", "--porcelain"])? .trim() .is_empty(); let current_branch = git_capture_in(&repo_root_path, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); let mut stashed = false; let preserve_enabled = cmd.preserve && !cmd.no_preserve; if preserve_enabled && current_branch != "HEAD" && current_branch != target_branch { let preserve_reason = switch_preserve_reason( &repo_root_path, ¤t_branch, &target_branch, has_changes, ); if let Some(reason) = preserve_reason { let snapshot_name = create_switch_safety_snapshot(&repo_root_path, ¤t_branch); match snapshot_name { Some(snapshot) => { println!( "==> Preserved branch '{}' as '{}' ({})", current_branch, snapshot, reason ); } None => { eprintln!( "warning: failed to create safety snapshot for '{}'; continuing switch", current_branch ); } } } } if stash_enabled && has_changes { let message = format!( "flow-switch-{}-{}", target_branch, Utc::now().format("%Y%m%d-%H%M%S") ); println!("==> Stashing local changes..."); if let Err(err) = git_run_in(&repo_root_path, &["stash", "push", "-u", "-m", &message]) { eprintln!( "warning: auto-stash failed, continuing without stash: {}", err ); } else { stashed = true; } } let mut switched_branch = target_branch.clone(); let switch_result = (|| -> Result<()> { let tracking = resolve_tracking_remote_and_fetch( &repo_root_path, &switched_branch, cmd.remote.as_deref(), )?; if git_ref_exists_in(&repo_root_path, &format!("refs/heads/{}", switched_branch)) { println!("==> Switching to local branch {}...", switched_branch); git_run_in(&repo_root_path, &["switch", &switched_branch])?; } else if let Some(remote) = tracking.remote.as_deref() { println!( "==> Creating {} from {}/{}...", switched_branch, remote, switched_branch ); let remote_branch = format!("{}/{}", remote, switched_branch); git_run_in( &repo_root_path, &["switch", "-c", &switched_branch, "--track", &remote_branch], )?; } else if let Some(pr_target) = resolution.pr.as_ref() { println!( "==> Branch '{}' not found on remotes; checking out {} via gh...", switched_branch, pr_target.display ); ensure_gh_available()?; let gh_args = build_gh_pr_checkout_args(&pr_target.checkout_target, cmd.remote.as_deref()); run_gh_in(&repo_root_path, &gh_args)?; let checked_out = git_capture_in(&repo_root_path, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); if checked_out.is_empty() || checked_out == "HEAD" { bail!( "checked out {}, but git is detached; please run `gh pr checkout {}` manually", pr_target.display, pr_target.checkout_target ); } switched_branch = checked_out; } else { let searched = if tracking.searched.is_empty() { cmd.remote .as_deref() .unwrap_or("upstream/origin") .to_string() } else { tracking.searched.join("/") }; bail!( "Branch '{}' not found locally or on remotes (searched: {}).", switched_branch, searched ); } if remote_branch_exists(&repo_root_path, "upstream", &switched_branch) { println!( "==> Updating flow upstream tracking to upstream/{}...", switched_branch ); git_run_in( &repo_root_path, &["config", "branch.upstream.remote", "upstream"], )?; let merge_ref = format!("refs/heads/{}", switched_branch); git_run_in( &repo_root_path, &["config", "branch.upstream.merge", &merge_ref], )?; sync_local_upstream_branch(&repo_root_path, &switched_branch)?; } if should_use_jj(&repo_root_path) { println!("==> Importing git refs into jj..."); jj_run_in(&repo_root_path, &["--quiet", "git", "import"])?; } Ok(()) })(); if let Err(err) = switch_result { if stashed { eprintln!("==> Restoring stashed changes after failed switch..."); let _ = git_run_in(&repo_root_path, &["stash", "pop"]); } return Err(err); } if stashed { println!("==> Restoring stashed changes..."); if let Err(err) = git_run_in(&repo_root_path, &["stash", "pop"]) { eprintln!( "warning: failed to restore stash automatically: {}\nRun `git stash list` and restore manually if needed.", err ); } } println!("✓ Switched to {}", switched_branch); if cmd.sync { println!("==> Running sync (default no push)..."); if let Err(sync_err) = run(SyncCommand { rebase: false, push: false, no_push: true, stash: true, stash_commits: false, allow_queue: false, create_repo: false, fix: true, no_fix: false, max_fix_attempts: 3, allow_review_issues: false, compact: false, }) { let _ = ensure_branch_attached(&repo_root_path, &switched_branch); return Err(sync_err); } ensure_branch_attached(&repo_root_path, &switched_branch)?; } Ok(()) } #[derive(Debug, Clone)] struct SwitchPrTarget { checkout_target: String, display: String, } #[derive(Debug, Clone)] struct SwitchTargetResolution { branch: String, pr: Option<SwitchPrTarget>, } #[derive(Debug, Deserialize)] struct SwitchPrView { #[serde(rename = "headRefName")] head_ref_name: String, } fn resolve_switch_target(repo_root: &Path, target: &str) -> Result<SwitchTargetResolution> { let trimmed = target.trim(); if let Some((repo, number)) = parse_github_pr_url(trimmed) { ensure_gh_available()?; let branch = resolve_pr_head_branch(repo_root, &number, Some(&repo))?; println!("==> Resolved PR {repo}#{number} to branch {branch}"); return Ok(SwitchTargetResolution { branch, pr: Some(SwitchPrTarget { checkout_target: number.clone(), display: format!("{repo}#{number}"), }), }); } let pr_number = if let Some(stripped) = trimmed.strip_prefix('#') { stripped.trim() } else { trimmed }; if !pr_number.is_empty() && pr_number.chars().all(|c| c.is_ascii_digit()) { ensure_gh_available()?; let branch = resolve_pr_head_branch(repo_root, pr_number, None)?; println!("==> Resolved PR #{pr_number} to branch {branch}"); return Ok(SwitchTargetResolution { branch, pr: Some(SwitchPrTarget { checkout_target: pr_number.to_string(), display: format!("#{pr_number}"), }), }); } Ok(SwitchTargetResolution { branch: trimmed.to_string(), pr: None, }) } fn resolve_pr_head_branch(repo_root: &Path, number: &str, repo: Option<&str>) -> Result<String> { let mut args: Vec<String> = vec![ "pr".to_string(), "view".to_string(), number.to_string(), "--json".to_string(), "headRefName".to_string(), ]; if let Some(repo) = repo.map(str::trim).filter(|s| !s.is_empty()) { args.push("--repo".to_string()); args.push(repo.to_string()); } let ref_args: Vec<&str> = args.iter().map(String::as_str).collect(); let out = gh_capture_in(repo_root, &ref_args)?; let parsed: SwitchPrView = serde_json::from_str(out.trim()) .with_context(|| format!("failed to parse gh pr view JSON for #{number}"))?; let branch = parsed.head_ref_name.trim(); if branch.is_empty() { bail!("PR #{} returned empty head branch", number); } Ok(branch.to_string()) } /// Checkout a GitHub PR safely while preserving local changes. pub fn run_checkout(cmd: CheckoutCommand) -> Result<()> { let _git_capture_cache_scope = GitCaptureCacheScope::begin(); if git_capture(&["rev-parse", "--git-dir"]).is_err() { bail!("Not a git repository"); } let target = cmd.target.trim(); if target.is_empty() { bail!("Checkout target cannot be empty"); } let repo_root = git_capture(&["rev-parse", "--show-toplevel"])? .trim() .to_string(); let repo_root_path = PathBuf::from(repo_root); let stash_enabled = cmd.stash && !cmd.no_stash; let has_changes = !git_capture_in(&repo_root_path, &["status", "--porcelain"])? .trim() .is_empty(); let mut stashed = false; if stash_enabled && has_changes { let message = format!( "flow-checkout-{}-{}", sanitize_checkout_label(target), Utc::now().format("%Y%m%d-%H%M%S") ); println!("==> Stashing local changes..."); if let Err(err) = git_run_in(&repo_root_path, &["stash", "push", "-u", "-m", &message]) { eprintln!( "warning: auto-stash failed, continuing without stash: {}", err ); } else { stashed = true; } } else if has_changes && !stash_enabled { println!("==> Continuing with local changes (auto-stash disabled)..."); } let checkout_result = (|| -> Result<()> { ensure_gh_available()?; let gh_args = build_gh_pr_checkout_args(target, cmd.remote.as_deref()); println!("==> Running: gh {}", gh_args.join(" ")); run_gh_in(&repo_root_path, &gh_args)?; if should_use_jj(&repo_root_path) { println!("==> Importing git refs into jj..."); if let Err(err) = jj_run_preferred_in(&repo_root_path, &["--quiet", "git", "import"]) { eprintln!( "warning: jj import failed after checkout: {}\nGit checkout succeeded.", err ); } } Ok(()) })(); if let Err(err) = checkout_result { if stashed { eprintln!("==> Restoring stashed changes after failed checkout..."); let _ = git_run_in(&repo_root_path, &["stash", "pop"]); } return Err(err); } if stashed { println!("==> Restoring stashed changes..."); if let Err(err) = git_run_in(&repo_root_path, &["stash", "pop"]) { eprintln!( "warning: failed to restore stash automatically: {}\nRun `git stash list` and restore manually if needed.", err ); } } let current = git_capture_in(&repo_root_path, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()) .trim() .to_string(); println!("✓ Checked out {}", current); Ok(()) } fn switch_preserve_reason( repo_root: &Path, current_branch: &str, target_branch: &str, has_changes: bool, ) -> Option<String> { if has_changes { return Some("uncommitted changes present".to_string()); } if switch_branch_has_commits_not_in_target(repo_root, current_branch, target_branch) { return Some(format!("contains commits not in {}", target_branch)); } if let Some((tracking_remote, tracking_branch)) = resolve_tracking_remote_branch_in(repo_root, Some(current_branch)) { let tracking_ref = format!("{}/{}", tracking_remote, tracking_branch); let tracking_git_ref = format!("refs/remotes/{}", tracking_ref); if !git_ref_exists_in(repo_root, &tracking_git_ref) { return Some(format!("tracking ref {} not fetched", tracking_ref)); } let ahead = git_capture_in( repo_root, &[ "rev-list", "--count", &format!("{}..{}", tracking_ref, current_branch), ], ) .ok() .and_then(|v| v.trim().parse::<u32>().ok()) .unwrap_or(0); if ahead > 0 { return Some(format!( "ahead of tracking {} by {} commit(s)", tracking_ref, ahead )); } } None } fn switch_branch_has_commits_not_in_target( repo_root: &Path, current_branch: &str, target_branch: &str, ) -> bool { let target_ref = if git_ref_exists_in(repo_root, &format!("refs/heads/{}", target_branch)) { target_branch.to_string() } else if git_ref_exists_in( repo_root, &format!("refs/remotes/upstream/{}", target_branch), ) { format!("upstream/{}", target_branch) } else if git_ref_exists_in(repo_root, &format!("refs/remotes/origin/{}", target_branch)) { format!("origin/{}", target_branch) } else { return true; }; git_capture_in( repo_root, &[ "rev-list", "--count", &format!("{}..{}", target_ref, current_branch), ], ) .ok() .and_then(|v| v.trim().parse::<u32>().ok()) .map(|count| count > 0) .unwrap_or(true) } fn create_switch_safety_snapshot(repo_root: &Path, current_branch: &str) -> Option<String> { let snapshot_base = format!( "f-switch-save/{}-{}", sanitize_checkout_label(current_branch), Utc::now().format("%Y%m%d-%H%M%S") ); let use_jj = should_use_jj(repo_root); let mut snapshot_name = snapshot_base.clone(); let mut suffix = 1; loop { let git_exists = git_ref_exists_in(repo_root, &format!("refs/heads/{}", snapshot_name)); let jj_exists = use_jj && jj_bookmark_exists(repo_root, &snapshot_name); if !git_exists && !jj_exists { break; } snapshot_name = format!("{}-{}", snapshot_base, suffix); suffix += 1; } if git_run_in(repo_root, &["branch", &snapshot_name, current_branch]).is_err() { return None; } if use_jj { if let Err(err) = jj_bookmark_create_or_set(repo_root, &snapshot_name, current_branch) { eprintln!( "warning: created git snapshot '{}' but failed to create/update jj bookmark: {}", snapshot_name, err ); } } Some(snapshot_name) } fn ensure_branch_attached(repo_root: &Path, target_branch: &str) -> Result<()> { let current = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()); let current = current.trim(); if current == target_branch { return Ok(()); } println!( "==> Re-attaching Git checkout to {} (current: {})...", target_branch, current ); git_run_in(repo_root, &["switch", target_branch]).with_context(|| { format!( "sync finished but could not switch back to '{}'; run `git switch {}` manually", target_branch, target_branch ) })?; Ok(()) } struct SwitchTrackingResolution { remote: Option<String>, searched: Vec<String>, } fn resolve_tracking_remote_and_fetch( repo_root: &Path, branch: &str, preferred_remote: Option<&str>, ) -> Result<SwitchTrackingResolution> { let mut candidates: Vec<String> = Vec::new(); let mut seen = HashSet::new(); let mut push_candidate = |remote: String| { let trimmed = remote.trim(); if trimmed.is_empty() { return; } let key = trimmed.to_string(); if seen.insert(key.clone()) { candidates.push(key); } }; if let Some(remote) = preferred_remote.map(str::trim).filter(|s| !s.is_empty()) { push_candidate(remote.to_string()); } for remote in ["upstream", "origin"] { push_candidate(remote.to_string()); } if let Ok(remotes_raw) = git_capture_in(repo_root, &["remote"]) { for remote in remotes_raw.lines() { push_candidate(remote.to_string()); } } let mut searched: Vec<String> = Vec::new(); for remote in candidates { if !remote_exists(repo_root, &remote) { continue; } searched.push(remote.clone()); println!("==> Fetching {}...", remote); let args = vec!["fetch", remote.as_str(), "--prune"]; if let Err(err) = git_run_in(repo_root, &args) { if preferred_remote.map(|r| r.trim()) == Some(remote.as_str()) { return Err(err); } continue; } if remote_branch_exists(repo_root, &remote, branch) { return Ok(SwitchTrackingResolution { remote: Some(remote), searched, }); } } Ok(SwitchTrackingResolution { remote: None, searched, }) } fn sync_local_upstream_branch(repo_root: &Path, branch: &str) -> Result<()> { let remote_ref = format!("refs/remotes/upstream/{}", branch); if !git_ref_exists_in(repo_root, &remote_ref) { return Ok(()); } let upstream_ref = format!("upstream/{}", branch); let current = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()); if git_ref_exists_in(repo_root, "refs/heads/upstream") { if current.trim() == "upstream" { git_run_in(repo_root, &["reset", "--hard", &upstream_ref])?; } else { git_run_in(repo_root, &["branch", "-f", "upstream", &upstream_ref])?; } } else { git_run_in(repo_root, &["branch", "upstream", &upstream_ref])?; } Ok(()) } fn remote_exists(repo_root: &Path, remote: &str) -> bool { git_capture_in(repo_root, &["remote", "get-url", remote]).is_ok() } fn sanitize_checkout_label(input: &str) -> String { let mut out = String::new(); let mut last_sep = false; for ch in input.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { out.push(ch); last_sep = false; } else if !last_sep { out.push('-'); last_sep = true; } } let trimmed = out.trim_matches('-'); if trimmed.is_empty() { "target".to_string() } else { trimmed.chars().take(32).collect() } } fn parse_github_pr_url(target: &str) -> Option<(String, String)> { let raw = target.trim().trim_end_matches('/'); let rest = raw .strip_prefix("https://github.com/") .or_else(|| raw.strip_prefix("http://github.com/"))?; let parts: Vec<&str> = rest.split('/').collect(); if parts.len() < 4 { return None; } if parts.get(2).copied() != Some("pull") { return None; } let owner = parts[0].trim(); let repo = parts[1].trim(); let number = parts[3].trim(); if owner.is_empty() || repo.is_empty() || number.is_empty() { return None; } if !number.chars().all(|c| c.is_ascii_digit()) { return None; } Some((format!("{}/{}", owner, repo), number.to_string())) } fn build_gh_pr_checkout_args(target: &str, preferred_remote: Option<&str>) -> Vec<String> { let mut args: Vec<String> = vec!["pr".to_string(), "checkout".to_string()]; if let Some((repo, number)) = parse_github_pr_url(target) { args.push(number); args.push("--repo".to_string()); args.push(repo); } else { args.push(target.to_string()); } if let Some(remote) = preferred_remote.map(str::trim).filter(|s| !s.is_empty()) { args.push("--remote".to_string()); args.push(remote.to_string()); } args } fn ensure_gh_available() -> Result<()> { let status = Command::new("gh") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .context("failed to run gh --version")?; if !status.success() { bail!("GitHub CLI (`gh`) is required for `f checkout`"); } Ok(()) } fn run_gh_in(repo_root: &Path, args: &[String]) -> Result<()> { let status = Command::new("gh") .current_dir(repo_root) .args(args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .with_context(|| format!("failed to run gh {}", args.join(" ")))?; if !status.success() { bail!("gh {} failed with status {}", args.join(" "), status); } Ok(()) } fn gh_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("gh") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run gh {}", args.join(" ")))?; if !output.status.success() { bail!( "gh {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim() ); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn remote_branch_exists(repo_root: &Path, remote: &str, branch: &str) -> bool { let reference = format!("refs/remotes/{}/{}", remote, branch); git_ref_exists_in(repo_root, &reference) } fn git_ref_exists_in(repo_root: &Path, reference: &str) -> bool { git_capture_in(repo_root, &["rev-parse", "--verify", reference]).is_ok() } #[derive(Clone, Debug)] struct TrackedRemoteRef { remote: String, branch: String, before_tip: Option<String>, } fn track_remote_ref( tracked: &mut Vec<TrackedRemoteRef>, repo_root: &Path, remote: &str, branch: &str, ) { if remote.trim().is_empty() || branch.trim().is_empty() { return; } if tracked .iter() .any(|item| item.remote == remote && item.branch == branch) { return; } tracked.push(TrackedRemoteRef { remote: remote.to_string(), branch: branch.to_string(), before_tip: remote_branch_tip(repo_root, remote, branch), }); } fn remote_branch_tip(repo_root: &Path, remote: &str, branch: &str) -> Option<String> { let reference = format!("refs/remotes/{}/{}", remote, branch); git_capture_in(repo_root, &["rev-parse", "--verify", &reference]) .ok() .map(|out| out.trim().to_string()) .filter(|sha| !sha.is_empty()) } fn short_commit_id(sha: &str) -> &str { let trimmed = sha.trim(); if trimmed.len() <= 8 { trimmed } else { &trimmed[..8] } } fn normalize_sync_commit_line(hash: &str, description: &str) -> String { let hash = hash.trim(); let description = description.trim(); if description.is_empty() { format!("{hash} (no description)") } else { format!("{hash} {description}") } } fn build_synced_commit_list(recorder: &SyncRecorder) -> Vec<String> { let mut seen_commits: Vec<(String, String)> = Vec::new(); let mut commits: Vec<String> = Vec::new(); for update in &recorder.remote_updates { for line in &update.commits { let trimmed = line.trim(); if trimmed.is_empty() { continue; } let (hash, description) = if let Some((hash, rest)) = trimmed.split_once(char::is_whitespace) { let hash = hash.trim(); if hash.is_empty() { continue; } (hash, rest.trim()) } else { (trimmed, "") }; let normalized_description = description.to_string(); let is_duplicate = seen_commits.iter().any(|(seen_hash, seen_description)| { seen_description == &normalized_description && (seen_hash.starts_with(hash) || hash.starts_with(seen_hash)) }); if !is_duplicate { seen_commits.push((hash.to_string(), normalized_description)); commits.push(trimmed.to_string()); } } } commits } fn jj_resolve_commit_id(repo_root: &Path, revset: &str) -> Option<String> { jj_capture_in( repo_root, &[ "log", "-r", revset, "--limit", "1", "--no-graph", "-T", "commit_id", ], ) .ok() .and_then(|out| out.lines().next().map(str::trim).map(str::to_string)) .filter(|value| !value.is_empty()) } fn jj_collect_sync_destination_commits( repo_root: &Path, source_revset: &str, dest_revset: &str, ) -> Result<Vec<String>> { let revset = format!("({})..({})", source_revset, dest_revset); let lines = jj_capture_in( repo_root, &[ "log", "-r", &revset, "--no-graph", "--reversed", "-T", r#"commit_id.shortest(8) ++ "\t" ++ description.first_line() ++ "\n""#, ], )?; Ok(lines .lines() .filter_map(|line| { let trimmed = line.trim_end(); if trimmed.is_empty() { return None; } let (hash, description) = trimmed.split_once('\t').unwrap_or((trimmed, "")); Some(normalize_sync_commit_line(hash, description)) }) .collect()) } fn record_jj_synced_destination_commits( repo_root: &Path, recorder: &mut SyncRecorder, source_revset: &str, dest_revset: &str, ) { let commits = match jj_collect_sync_destination_commits(repo_root, source_revset, dest_revset) { Ok(commits) => commits, Err(_) => return, }; if commits.is_empty() { return; } let (branch, remote) = match dest_revset.rsplit_once('@') { Some((branch, remote)) if !branch.trim().is_empty() && !remote.trim().is_empty() => { (branch.to_string(), format!("synced:{remote}")) } _ => ("dest".to_string(), "synced".to_string()), }; recorder.add_remote_update(SyncRemoteUpdate { remote, branch, before_tip: jj_resolve_commit_id(repo_root, source_revset), after_tip: jj_resolve_commit_id(repo_root, dest_revset).unwrap_or_default(), commit_count: commits.len(), commits, }); } fn copy_sync_output_to_clipboard(text: &str) -> Result<bool> { if std::env::var("FLOW_NO_CLIPBOARD").is_ok() { return Ok(false); } #[cfg(target_os = "macos")] { let mut child = Command::new("pbcopy") .stdin(Stdio::piped()) .spawn() .context("failed to spawn pbcopy")?; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(text.as_bytes())?; } child.wait()?; return Ok(true); } #[cfg(target_os = "linux")] { let result = Command::new("xclip") .arg("-selection") .arg("clipboard") .stdin(Stdio::piped()) .spawn(); let mut child = match result { Ok(c) => c, Err(_) => Command::new("xsel") .arg("--clipboard") .arg("--input") .stdin(Stdio::piped()) .spawn() .context("failed to spawn xclip or xsel")?, }; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(text.as_bytes())?; } child.wait()?; return Ok(true); } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { bail!("clipboard not supported on this platform"); } } fn print_fetched_remote_commits( repo_root: &Path, tracked: &[TrackedRemoteRef], recorder: &mut SyncRecorder, _compact: bool, ) { for item in tracked { let Some(after_tip) = remote_branch_tip(repo_root, &item.remote, &item.branch) else { continue; }; if item.before_tip.as_deref() == Some(after_tip.as_str()) { continue; } let label = format!("{}/{}", item.remote, item.branch); match item.before_tip.as_deref() { None => { recorder.add_remote_update(SyncRemoteUpdate { remote: item.remote.clone(), branch: item.branch.clone(), before_tip: None, after_tip: after_tip.clone(), commit_count: 0, commits: Vec::new(), }); recorder.record( "jj", format!("fetched {} at {}", label, short_commit_id(&after_tip)), ); } Some(before_tip) => { let range = format!("{}..{}", before_tip, after_tip); let lines = git_capture_in( repo_root, &[ "log", "--oneline", "--abbrev=8", "--no-decorate", "--reverse", &range, ], ) .unwrap_or_default(); let commits: Vec<&str> = lines .lines() .map(str::trim) .filter(|line| !line.is_empty()) .collect(); if commits.is_empty() { recorder.add_remote_update(SyncRemoteUpdate { remote: item.remote.clone(), branch: item.branch.clone(), before_tip: Some(before_tip.to_string()), after_tip: after_tip.clone(), commit_count: 0, commits: Vec::new(), }); recorder.record( "jj", format!( "fetched {} {} -> {}", label, short_commit_id(before_tip), short_commit_id(&after_tip) ), ); } else { recorder.add_remote_update(SyncRemoteUpdate { remote: item.remote.clone(), branch: item.branch.clone(), before_tip: Some(before_tip.to_string()), after_tip: after_tip.clone(), commit_count: commits.len(), commits: commits.iter().map(|line| (*line).to_string()).collect(), }); recorder.record( "jj", format!("fetched {} (+{} commits)", label, commits.len()), ); } } } } } fn run_jj_sync( repo_root: &Path, cmd: &SyncCommand, auto_fix: bool, recorder: &mut SyncRecorder, ) -> Result<()> { // Avoid git operations in progress. if is_rebase_in_progress() || is_merge_in_progress() { bail!("Git operation in progress. Run `f git-repair` first."); } let unmerged = git_capture(&["diff", "--name-only", "--diff-filter=U"]).unwrap_or_default(); if !unmerged.trim().is_empty() { bail!("Unmerged files detected. Resolve them before syncing."); } let head_ref = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()); let head_ref = head_ref.trim(); let current_branch = if head_ref == "HEAD" || head_ref.is_empty() { recorder.record("jj", "detached head (ignored, using default branch)"); jj_default_branch(repo_root) } else { head_ref.to_string() }; let push_remote = config::preferred_git_remote_for_repo(repo_root); let has_push_remote = git_capture_in(repo_root, &["remote", "get-url", &push_remote]).is_ok(); let push_remote_reachable = has_push_remote && git_capture_in(repo_root, &["ls-remote", "--exit-code", "-q", &push_remote]).is_ok(); let has_origin = git_capture_in(repo_root, &["remote", "get-url", "origin"]).is_ok(); let has_upstream = git_capture_in(repo_root, &["remote", "get-url", "upstream"]).is_ok(); let origin_reachable = has_origin && git_capture_in(repo_root, &["ls-remote", "--exit-code", "-q", "origin"]).is_ok(); let should_push = sync_should_push(cmd); let origin_default_branch = if !has_upstream && has_origin && origin_reachable { origin_default_branch_for_feature_sync(repo_root, ¤t_branch) } else { None }; // Keep jj fetch output small. In most workflows, only the current branch + upstream trunk are // needed for a sync/rebase. let mut upstream_branch_opt = resolve_upstream_branch_in(repo_root, Some(¤t_branch)); let upstream_branch_for_fetch = upstream_branch_opt .clone() .unwrap_or_else(|| jj_default_branch(repo_root)); let mut tracked_refs: Vec<TrackedRemoteRef> = Vec::new(); if has_origin || has_upstream { println!("==> Fetching remotes via jj..."); let mut fetched_any = false; let mut failures: Vec<String> = Vec::new(); if has_origin && origin_reachable { track_remote_ref(&mut tracked_refs, repo_root, "origin", ¤t_branch); recorder.record( "jj", format!("jj git fetch --remote origin --branch {}", current_branch), ); if let Err(err) = jj_run_in( repo_root, &[ "--quiet", "git", "fetch", "--remote", "origin", "--branch", ¤t_branch, ], ) { failures.push(format!("origin: {}", err)); } else { fetched_any = true; } if let Some(default_branch) = origin_default_branch.as_deref() { track_remote_ref(&mut tracked_refs, repo_root, "origin", default_branch); recorder.record( "jj", format!("jj git fetch --remote origin --branch {}", default_branch), ); if let Err(err) = jj_run_in( repo_root, &[ "--quiet", "git", "fetch", "--remote", "origin", "--branch", default_branch, ], ) { failures.push(format!("origin default {}: {}", default_branch, err)); } else { fetched_any = true; } } } else if has_origin { recorder.record("jj", "skip origin (unreachable)"); } if has_push_remote && push_remote != "origin" && push_remote != "upstream" && push_remote_reachable { track_remote_ref(&mut tracked_refs, repo_root, &push_remote, ¤t_branch); recorder.record( "jj", format!( "jj git fetch --remote {} --branch {}", push_remote, current_branch ), ); if let Err(err) = jj_run_in( repo_root, &[ "--quiet", "git", "fetch", "--remote", &push_remote, "--branch", ¤t_branch, ], ) { failures.push(format!("{}: {}", push_remote, err)); } else { fetched_any = true; } } else if has_push_remote && push_remote != "origin" && push_remote != "upstream" && !push_remote_reachable { recorder.record("jj", format!("skip {} (unreachable)", push_remote)); } if has_upstream { track_remote_ref( &mut tracked_refs, repo_root, "upstream", &upstream_branch_for_fetch, ); recorder.record( "jj", format!( "jj git fetch --remote upstream --branch {}", upstream_branch_for_fetch ), ); if let Err(primary_err) = jj_run_in( repo_root, &[ "--quiet", "git", "fetch", "--remote", "upstream", "--branch", &upstream_branch_for_fetch, ], ) { recorder.record( "jj", format!( "jj git fetch upstream branch {} failed, retrying full fetch", upstream_branch_for_fetch ), ); if let Err(fallback_err) = jj_run_in( repo_root, &["--quiet", "git", "fetch", "--remote", "upstream"], ) { failures.push(format!( "upstream: {} (fallback failed: {})", primary_err, fallback_err )); } else { fetched_any = true; } } else { fetched_any = true; } } if fetched_any { recorder.record("jj", "jj git import"); let _ = jj_run_in(repo_root, &["--quiet", "git", "import"]); print_fetched_remote_commits(repo_root, &tracked_refs, recorder, cmd.compact); // Re-resolve after fetch/import so we can pick up newly discovered upstream refs. upstream_branch_opt = resolve_upstream_branch_in(repo_root, Some(¤t_branch)); } else if !failures.is_empty() { bail!("jj git fetch failed: {}", failures.join(", ")); } } let push_remote_url = git_capture_in(repo_root, &["remote", "get-url", &push_remote]).unwrap_or_default(); let upstream_url = git_capture_in(repo_root, &["remote", "get-url", "upstream"]).unwrap_or_default(); let is_read_only = has_upstream && normalize_git_url(&push_remote_url) == normalize_git_url(&upstream_url); let mut dest_ref: Option<String> = None; if has_upstream { if let Some(branch) = upstream_branch_opt { dest_ref = Some(format!("{}@upstream", branch)); } } else if let Some(default_branch) = origin_default_branch { dest_ref = Some(format!("{}@origin", default_branch)); } if dest_ref.is_none() && has_push_remote { dest_ref = Some(format!("{}@{}", current_branch, push_remote)); } let mut did_rebase = false; let mut did_stash_commits = false; let mut needs_git_export = false; if let Some(dest) = dest_ref.clone() { let has_branch_bookmark = jj_bookmark_exists(repo_root, ¤t_branch); let branch_sync_source = jj_branch_sync_source_rev(repo_root, ¤t_branch); record_jj_synced_destination_commits(repo_root, recorder, &branch_sync_source, &dest); if cmd.stash_commits { if jj_has_divergence(repo_root, &branch_sync_source, &dest)? { let stash_name = jj_stash_commits(repo_root, ¤t_branch, &dest)?; println!("==> Stashed local JJ commits to {}", stash_name); recorder.record("stash", format!("jj stash {}", stash_name)); recorder.set_stashed(true); did_rebase = true; did_stash_commits = true; needs_git_export = true; } } if !did_stash_commits { if has_branch_bookmark { if jj_has_divergence(repo_root, &branch_sync_source, &dest)? { println!( "==> Rebasing branch {} with jj onto {}...", current_branch, dest ); let preempt_ignore_immutable = branch_tip_matches_remote( repo_root, ¤t_branch, if is_read_only { "upstream" } else { &push_remote }, ); if preempt_ignore_immutable { recorder.record( "jj", format!( "branch {} matches {}/{}; preemptively using --ignore-immutable", current_branch, push_remote, current_branch ), ); } let initial_rebase_args: Vec<&str> = if preempt_ignore_immutable { vec![ "rebase", "--ignore-immutable", "-b", branch_sync_source.as_str(), "-d", &dest, ] } else { vec!["rebase", "-b", branch_sync_source.as_str(), "-d", &dest] }; recorder.record( "jj", if preempt_ignore_immutable { format!( "jj rebase --ignore-immutable -b {} -d {}", branch_sync_source, dest ) } else { format!("jj rebase -b {} -d {}", branch_sync_source, dest) }, ); if let Err(err) = jj_run_in(repo_root, &initial_rebase_args) { recorder.record("jj", "jj branch rebase failed"); if !preempt_ignore_immutable { println!( "==> Rebase blocked by immutable commits; retrying with --ignore-immutable..." ); recorder.record("jj", "jj branch rebase retry --ignore-immutable"); jj_run_in( repo_root, &[ "rebase", "--ignore-immutable", "-b", branch_sync_source.as_str(), "-d", &dest, ], )?; } else { return Err(err); } } did_rebase = true; needs_git_export = true; } else { recorder.record( "jj", format!("jj bookmark set {} -r {}", current_branch, dest), ); let ff_output = jj_capture_in( repo_root, &["bookmark", "set", ¤t_branch, "-r", &dest], )?; let ff_trimmed = ff_output.trim(); if ff_trimmed.is_empty() || ff_trimmed.contains("Nothing changed") || ff_trimmed.contains("nothing changed") { println!(" {} already up to date with {}", current_branch, dest); } else { println!("==> Fast-forwarded {} to {}", current_branch, dest); } needs_git_export = true; } // After syncing the branch bookmark, also rebase the working // copy onto the new destination so files reflect latest state. recorder.record("jj", format!("jj rebase -d {} (working copy)", dest)); match jj_capture_in(repo_root, &["rebase", "-d", &dest]) { Ok(_) => { println!("==> Rebased working copy onto {}", dest); did_rebase = true; } Err(_) => { // Non-fatal: working copy may already be at destination } } } else { println!("==> Rebasing with jj onto {}...", dest); recorder.record("jj", format!("jj rebase -d {}", dest)); if let Err(err) = jj_run_in(repo_root, &["rebase", "-d", &dest]) { recorder.record("jj", "jj rebase failed"); println!( "==> Rebase blocked by immutable commits; retrying with --ignore-immutable..." ); recorder.record("jj", "jj rebase retry --ignore-immutable"); if let Err(retry_err) = jj_run_in(repo_root, &["rebase", "--ignore-immutable", "-d", &dest]) { // If even --ignore-immutable fails, return the original error. let _ = retry_err; return Err(err); } } did_rebase = true; } if commit::commit_queue_has_entries(repo_root) { if let Ok(updated) = commit::refresh_commit_queue(repo_root) { if updated > 0 { recorder.record("queue", format!("refreshed {} queued commits", updated)); println!("==> Updated {} queued commit(s) after rebase", updated); } } } } if needs_git_export { recorder.record("jj", "jj git export"); jj_run_in(repo_root, &["--quiet", "git", "export"])?; // After jj git export, git HEAD may be detached (or on a jj/keep/ ref) // because the jj working copy commit isn't on any bookmark. Re-attach // HEAD to the current branch so the user's shell prompt stays sane. let git_head = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_default(); let git_head = git_head.trim(); if git_head == "HEAD" || git_head.starts_with("jj/keep/") { if git_ref_exists_in(repo_root, &format!("refs/heads/{}", current_branch)) { let branch_sha = git_capture_in( repo_root, &["rev-parse", &format!("refs/heads/{}", current_branch)], ) .unwrap_or_default(); let branch_sha = branch_sha.trim(); if !branch_sha.is_empty() { // Point HEAD at the branch symbolically, then reset to its tip. let _ = git_run_in( repo_root, &[ "symbolic-ref", "HEAD", &format!("refs/heads/{}", current_branch), ], ); let _ = git_run_in(repo_root, &["reset", "--mixed", "--quiet", branch_sha]); recorder.record("jj", format!("re-attached HEAD to {}", current_branch)); } } } } } else { println!("==> No remotes configured, skipping rebase"); recorder.record("jj", "skipped (no remotes)"); } // Fork push override: redirect to private fork remote if configured. let (mut push_remote, mut has_push_remote, mut push_remote_reachable, mut is_read_only) = ( push_remote, has_push_remote, push_remote_reachable, is_read_only, ); if should_push { if let Some((fork_remote, fork_owner, fork_repo)) = resolve_fork_push_target(repo_root) { let target_url = push::build_github_ssh_url(&fork_owner, &fork_repo); if let Err(e) = push::ensure_remote_points_to_target( repo_root, &fork_remote, &target_url, None, true, ) { eprintln!("Warning: could not set up fork remote: {}", e); } else { push::ensure_github_repo_exists(&fork_owner, &fork_repo).ok(); // Let jj know about the new remote. let _ = jj_capture_in(repo_root, &["git", "fetch", "--remote", &fork_remote]); println!( "==> Fork push enabled: {}/{} (remote: {})", fork_owner, fork_repo, fork_remote ); push_remote = fork_remote; has_push_remote = true; push_remote_reachable = true; is_read_only = false; } } } // Review-todo push gate (jj sync path) if should_push && !check_review_todo_push_gate(repo_root, cmd.allow_review_issues, recorder) { recorder.record("push", "blocked by review-todo gate"); bail!("Push blocked by open review todos. Use --allow-review-issues to override."); } if has_push_remote && should_push { if is_read_only { println!( "==> Skipping push (remote '{}' == upstream, read-only clone)", push_remote ); println!(" To push, create a fork first: gh repo fork --remote"); recorder.record("push", "skipped (push remote == upstream)"); } else if !push_remote_reachable { if cmd.create_repo && push_remote == "origin" { println!("==> Creating origin repo..."); if try_create_origin_repo()? { println!("==> Pushing to {}...", push_remote); git_run(&["push", "-u", &push_remote, ¤t_branch])?; recorder.record( "push", format!("created repo and pushed to {}", push_remote), ); } else { println!(" Could not create repo, skipping push"); recorder.record("push", "skipped (create repo failed)"); } } else { println!("==> Remote '{}' unreachable, skipping push", push_remote); println!(" The remote may be missing, private, or auth/network failed."); if push_remote == "origin" { println!(" Use --create-repo if origin does not exist yet."); } else { println!( " Create/fix remote '{}' and re-run sync (or set [git].remote).", push_remote ); } recorder.record( "push", format!("skipped (remote unreachable: {})", push_remote), ); } } else { println!("==> Pushing to {}...", push_remote); let push_result = if did_rebase { push_with_autofix_force( ¤t_branch, &push_remote, auto_fix, cmd.max_fix_attempts, ) } else { push_with_autofix( ¤t_branch, &push_remote, auto_fix, cmd.max_fix_attempts, ) }; if let Err(e) = push_result { recorder.record("push", "push failed"); return Err(e); } recorder.record("push", "push complete"); } } else if cmd.no_push { recorder.record("push", "skipped (--no-push)"); } else if has_push_remote { recorder.record("push", "skipped (default; use --push)"); } else { recorder.record("push", format!("skipped (missing remote: {})", push_remote)); } // Check for jj conflicts left after rebase let has_conflicts = jj_capture_in( repo_root, &["log", "-r", "conflicts()", "--no-graph", "-T", "commit_id"], ) .map(|out| !out.trim().is_empty()) .unwrap_or(false); if has_conflicts { let conflict_details = jj_capture_in(repo_root, &["log", "-r", "conflicts()", "--no-graph"]) .unwrap_or_default(); println!("\n⚠ Sync complete (jj) but conflicts remain:"); for line in conflict_details.lines().filter(|l| !l.trim().is_empty()) { println!(" {}", line.trim()); } println!("\nResolve with: jj resolve"); recorder.record("complete", "sync complete (jj) with conflicts"); } else { println!("\n✓ Sync complete (jj)!"); recorder.record("complete", "sync complete (jj)"); } Ok(()) } /// Sync from upstream remote into current branch. fn sync_upstream_internal( repo_root: &Path, current_branch: &str, auto_fix: bool, recorder: &mut SyncRecorder, ) -> Result<()> { // Fetch upstream — tolerate case-insensitive ref collisions (macOS) let fetch = Command::new("git") .current_dir(repo_root) .args(["fetch", "upstream", "--prune"]) .output() .context("failed to run git fetch upstream")?; if !fetch.status.success() { let stderr = String::from_utf8_lossy(&fetch.stderr); if stderr.contains("case-insensitive filesystem") { eprintln!( " Warning: upstream has refs that differ only in case; fetch continued anyway" ); } else { bail!("git fetch upstream --prune failed: {}", stderr.trim()); } } recorder.record("upstream", "fetched upstream"); // Determine upstream branch let upstream_branch = match resolve_upstream_branch_in(repo_root, Some(current_branch)) { Some(branch) => branch, None => { println!(" Cannot determine upstream branch, skipping upstream sync"); recorder.record("upstream", "skipped (cannot determine upstream branch)"); return Ok(()); } }; // Update local upstream branch if it exists let local_upstream_exists = git_capture_in(repo_root, &["rev-parse", "--verify", "refs/heads/upstream"]).is_ok(); if local_upstream_exists { let upstream_ref = format!("upstream/{}", upstream_branch); git_run_in(repo_root, &["branch", "-f", "upstream", &upstream_ref])?; } merge_remote_branch_into_current( repo_root, "upstream", &upstream_branch, current_branch, auto_fix, recorder, "upstream", ) } fn sync_origin_default_internal( repo_root: &Path, current_branch: &str, origin_default_branch: &str, auto_fix: bool, recorder: &mut SyncRecorder, ) -> Result<()> { let refspec = format!( "+refs/heads/{}:refs/remotes/origin/{}", origin_default_branch, origin_default_branch ); git_run_in(repo_root, &["fetch", "origin", "--prune", &refspec])?; recorder.record( "upstream", format!("fetched origin {}", origin_default_branch), ); merge_remote_branch_into_current( repo_root, "origin", origin_default_branch, current_branch, auto_fix, recorder, "origin-default", ) } fn merge_remote_branch_into_current( repo_root: &Path, remote: &str, remote_branch: &str, current_branch: &str, auto_fix: bool, recorder: &mut SyncRecorder, stage: &str, ) -> Result<()> { let remote_ref = format!("{}/{}", remote, remote_branch); let behind = git_capture_in( repo_root, &[ "rev-list", "--count", &format!("{}..{}", current_branch, remote_ref), ], ) .ok() .and_then(|s| s.trim().parse::<u32>().ok()) .unwrap_or(0); if behind == 0 { println!(" Already up to date with {}", remote_ref); recorder.record(stage, format!("already up to date with {}", remote_ref)); return Ok(()); } println!(" Merging {} commits from {}...", behind, remote_ref); recorder.record( stage, format!("merging {} commits from {}", behind, remote_ref), ); match git_run_in(repo_root, &["merge", "--ff-only", &remote_ref]) { Ok(()) => { recorder.record(stage, format!("fast-forwarded to {}", remote_ref)); return Ok(()); } Err(err) if is_git_index_lock_error(&err.to_string()) => { bail!( "Git index lock detected during merge. Remove stale .git/index.lock (if no git process is running) and re-run." ); } Err(_) => {} } match git_run_in(repo_root, &["merge", &remote_ref, "--no-edit"]) { Ok(()) => { recorder.record(stage, format!("merged {} with commit", remote_ref)); return Ok(()); } Err(err) if is_git_index_lock_error(&err.to_string()) => { bail!( "Git index lock detected during merge. Remove stale .git/index.lock (if no git process is running) and re-run." ); } Err(_) => {} } let should_fix = auto_fix || prompt_for_auto_fix()?; if should_fix { println!(" Attempting auto-fix..."); if try_resolve_conflicts()? { let _ = git_run_in(repo_root, &["add", "-A"]); let _ = Command::new("git") .current_dir(repo_root) .args(["commit", "--no-edit"]) .output(); println!(" ✓ Conflicts auto-resolved"); recorder.record(stage, "conflicts auto-resolved"); return Ok(()); } } recorder.record(stage, "merge conflicts unresolved"); bail!( "Merge conflicts with {}. Resolve manually:\n git status\n # fix conflicts\n git add . && git commit", remote_ref ); } fn origin_default_branch_for_feature_sync( repo_root: &Path, current_branch: &str, ) -> Option<String> { let current = current_branch.trim(); if current.is_empty() || current == "HEAD" { return None; } let default_branch = resolve_remote_default_branch_in(repo_root, "origin")?; if current == default_branch { return None; } if !remote_branch_exists(repo_root, "origin", &default_branch) { return None; } Some(default_branch) } fn resolve_remote_default_branch_in(repo_root: &Path, remote: &str) -> Option<String> { let head_ref = format!("refs/remotes/{}/HEAD", remote); if let Ok(symbolic) = git_capture_in(repo_root, &["symbolic-ref", &head_ref]) { let prefix = format!("refs/remotes/{}/", remote); if let Some(branch) = symbolic.trim().strip_prefix(&prefix) { if !branch.is_empty() { return Some(branch.to_string()); } } } let preferred = jj_default_branch(repo_root); if remote_branch_exists(repo_root, remote, &preferred) { return Some(preferred); } for candidate in ["main", "master", "dev", "trunk"] { if remote_branch_exists(repo_root, remote, candidate) { return Some(candidate.to_string()); } } None } fn is_jj_corruption_error(err: &anyhow::Error) -> bool { let msg = err.to_string().to_lowercase(); msg.contains("failed to load short-prefixes index") || msg.contains("unexpected error from commit backend") || msg.contains("current working-copy commit not found") || msg.contains("failed to check out a commit") || (msg.contains("object ") && msg.contains(" not found")) || (msg.contains("jj git fetch failed") && msg.contains("object")) } fn is_git_index_lock_error(message: &str) -> bool { let lower = message.to_lowercase(); lower.contains("index.lock") || lower.contains("another git process seems to be running") || lower.contains("could not write index") } fn parse_branch_merge_ref(value: &str) -> Option<String> { let trimmed = value.trim(); if trimmed.is_empty() { return None; } Some(trimmed.trim_start_matches("refs/heads/").to_string()) } fn parse_tracking_ref(value: &str) -> Option<(String, String)> { let trimmed = value.trim(); if trimmed.is_empty() { return None; } let (remote, branch) = trimmed.split_once('/')?; if remote.is_empty() || branch.is_empty() { return None; } Some((remote.to_string(), branch.to_string())) } fn resolve_tracking_remote_branch_in( repo_root: &Path, current_branch: Option<&str>, ) -> Option<(String, String)> { let current_branch = current_branch .map(str::trim) .filter(|value| !value.is_empty() && *value != "HEAD"); if let Some(branch) = current_branch { if let Ok(remote) = git_capture_in( repo_root, &["config", "--get", &format!("branch.{}.remote", branch)], ) { let remote = remote.trim(); if !remote.is_empty() { if let Ok(merge_ref) = git_capture_in( repo_root, &["config", "--get", &format!("branch.{}.merge", branch)], ) { if let Some(merge_branch) = parse_branch_merge_ref(&merge_ref) { if !merge_branch.is_empty() { return Some((remote.to_string(), merge_branch)); } } } } } } if let Ok(upstream) = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "@{upstream}"]) { return parse_tracking_ref(&upstream); } None } fn list_upstream_remote_branches(repo_root: &Path) -> Vec<String> { let output = git_capture_in( repo_root, &[ "for-each-ref", "--format=%(refname:short)", "refs/remotes/upstream", ], ) .unwrap_or_default(); let mut branches = Vec::new(); for line in output.lines() { let value = line.trim(); if value.is_empty() || value == "upstream/HEAD" { continue; } if let Some(rest) = value.strip_prefix("upstream/") { if !rest.is_empty() { branches.push(rest.to_string()); } } } branches.sort(); branches.dedup(); branches } fn resolve_upstream_branch_in(repo_root: &Path, current_branch: Option<&str>) -> Option<String> { let current_branch = current_branch .map(str::trim) .filter(|value| !value.is_empty()); if let Some(branch) = current_branch { if let Ok(remote) = git_capture_in( repo_root, &["config", "--get", &format!("branch.{}.remote", branch)], ) { if remote.trim() == "upstream" { if let Ok(merge_ref) = git_capture_in( repo_root, &["config", "--get", &format!("branch.{}.merge", branch)], ) { if let Some(parsed) = parse_branch_merge_ref(&merge_ref) { return Some(parsed); } } } } } if let Ok(merge_ref) = git_capture_in(repo_root, &["config", "--get", "branch.upstream.merge"]) { if let Some(parsed) = parse_branch_merge_ref(&merge_ref) { return Some(parsed); } } if let Ok(head_ref) = git_capture_in(repo_root, &["symbolic-ref", "refs/remotes/upstream/HEAD"]) { let parsed = head_ref.trim().replace("refs/remotes/upstream/", ""); if !parsed.is_empty() { return Some(parsed); } } if let Some(branch) = current_branch { let reference = format!("refs/remotes/upstream/{}", branch); if git_ref_exists_in(repo_root, &reference) { return Some(branch.to_string()); } } let remote_branches = list_upstream_remote_branches(repo_root); for candidate in ["main", "master", "dev", "trunk"] { if remote_branches.iter().any(|b| b == candidate) { return Some(candidate.to_string()); } } remote_branches.into_iter().next() } fn resolve_sync_branch_for_queue_guard(repo_root: &Path) -> Option<String> { let head = git_capture_in(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .ok() .map(|value| value.trim().to_string()) .unwrap_or_default(); if !head.is_empty() && head != "HEAD" { return Some(head); } if let Some(branch) = resolve_rebase_head_branch(repo_root) { return Some(branch); } resolve_branch_containing_head(repo_root) } fn resolve_rebase_head_branch(repo_root: &Path) -> Option<String> { let git_dir = git_capture_in(repo_root, &["rev-parse", "--git-dir"]) .ok() .map(|value| value.trim().to_string())?; let git_dir_path = if Path::new(&git_dir).is_absolute() { PathBuf::from(git_dir) } else { repo_root.join(git_dir) }; for rel in ["rebase-merge/head-name", "rebase-apply/head-name"] { let path = git_dir_path.join(rel); let Ok(raw) = fs::read_to_string(&path) else { continue; }; let branch = raw.trim().trim_start_matches("refs/heads/").trim(); if !branch.is_empty() && branch != "HEAD" { return Some(branch.to_string()); } } None } fn resolve_branch_containing_head(repo_root: &Path) -> Option<String> { let output = git_capture_in( repo_root, &["branch", "--format=%(refname:short)", "--contains", "HEAD"], ) .ok()?; output .lines() .map(str::trim) .find(|value| !value.is_empty() && *value != "(no branch)" && *value != "HEAD") .map(|value| value.to_string()) } fn should_use_jj(repo_root: &Path) -> bool { has_jj_workspace(repo_root) && jj_cli_available() && jj_workspace_healthy(repo_root) } fn has_jj_workspace(repo_root: &Path) -> bool { repo_root.join(".jj").exists() } fn jj_cli_available() -> bool { let status = Command::new("jj") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); status.map(|s| s.success()).unwrap_or(false) } fn jj_workspace_healthy(repo_root: &Path) -> bool { if env::var("FLOW_SYNC_SKIP_JJ_HEALTHCHECK") .ok() .map(|v| { let t = v.trim(); t == "1" || t.eq_ignore_ascii_case("true") }) .unwrap_or(false) { return true; } Command::new("jj") .current_dir(repo_root) .arg("status") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } fn jj_default_branch(repo_root: &Path) -> String { let local_config = repo_root.join("flow.toml"); if local_config.exists() { if let Ok(cfg) = config::load(&local_config) { if let Some(jj_cfg) = cfg.jj { if let Some(branch) = jj_cfg.default_branch { return branch; } } } } let global_config = config::default_config_path(); if global_config.exists() { if let Ok(cfg) = config::load(&global_config) { if let Some(jj_cfg) = cfg.jj { if let Some(branch) = jj_cfg.default_branch { return branch; } } } } if git_ref_exists("refs/heads/main") || git_ref_exists("refs/remotes/origin/main") { return "main".to_string(); } if git_ref_exists("refs/heads/master") || git_ref_exists("refs/remotes/origin/master") { return "master".to_string(); } "main".to_string() } fn git_ref_exists(reference: &str) -> bool { git_capture(&["rev-parse", "--verify", reference]).is_ok() } fn jj_run_in(repo_root: &Path, args: &[&str]) -> Result<()> { let output = Command::new("jj") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() { print!("{}", stdout); } let stderr = String::from_utf8_lossy(&output.stderr); for line in stderr.lines() { if line.contains("Refused to snapshot") { continue; } eprintln!("{}", line); } if !output.status.success() { bail!("jj {} failed", args.join(" ")); } Ok(()) } fn jj_preferred_binary() -> std::path::PathBuf { if let Ok(path) = std::env::var("FLOW_JJ_BIN") { let candidate = PathBuf::from(path); if candidate.exists() { return candidate; } } if let Ok(home) = std::env::var("HOME") { let local_dev_jj = PathBuf::from(home).join("repos/jj-vcs/jj/target/release/jj"); if local_dev_jj.exists() { return local_dev_jj; } } PathBuf::from("jj") } fn jj_run_preferred_in(repo_root: &Path, args: &[&str]) -> Result<()> { let jj_bin = jj_preferred_binary(); let output = Command::new(&jj_bin) .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run {} {}", jj_bin.display(), args.join(" ")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let concise = stderr .lines() .chain(stdout.lines()) .map(str::trim) .find(|line| !line.is_empty()) .unwrap_or("jj command failed"); bail!( "{} {} failed: {}", jj_bin.display(), args.join(" "), concise ); } Ok(()) } fn jj_bookmark_exists(repo_root: &Path, name: &str) -> bool { let output = jj_capture_in(repo_root, &["bookmark", "list"]).unwrap_or_default(); output .lines() .any(|line| line.trim_start().starts_with(name)) } fn jj_bookmark_create_or_set(repo_root: &Path, name: &str, rev: &str) -> Result<()> { if jj_bookmark_exists(repo_root, name) { return jj_run_in(repo_root, &["bookmark", "set", name, "-r", rev]); } match jj_run_in(repo_root, &["bookmark", "create", name, "-r", rev]) { Ok(()) => Ok(()), Err(create_err) => { if jj_bookmark_exists(repo_root, name) { jj_run_in(repo_root, &["bookmark", "set", name, "-r", rev]).with_context(|| { format!("create failed ({create_err}); bookmark exists, but set also failed") }) } else { Err(create_err) } } } } fn jj_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("jj") .current_dir(repo_root) .args(args) .output() .with_context(|| format!("failed to run jj {}", args.join(" ")))?; if !output.status.success() { bail!("jj {} failed", args.join(" ")); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn jj_revset_string_literal(value: &str) -> String { serde_json::to_string(value).unwrap_or_else(|_| format!("\"{}\"", value)) } fn jj_local_bookmark_revset(name: &str) -> String { format!( "bookmarks(exact:{}) & mutable()", jj_revset_string_literal(name) ) } fn jj_branch_sync_source_rev(repo_root: &Path, branch: &str) -> String { if jj_bookmark_exists(repo_root, branch) { jj_local_bookmark_revset(branch) } else { "@".to_string() } } fn jj_has_divergence(repo_root: &Path, source_revset: &str, dest: &str) -> Result<bool> { let revset = format!("({})..({})", dest, source_revset); let output = jj_capture_in( repo_root, &["log", "-r", &revset, "--no-graph", "-T", "commit_id"], )?; Ok(!output.trim().is_empty()) } fn branch_tip_matches_remote(repo_root: &Path, branch: &str, remote: &str) -> bool { let local_ref = format!("refs/heads/{}", branch); let remote_ref = format!("refs/remotes/{}/{}", remote, branch); let local_sha = match git_capture_in(repo_root, &["rev-parse", &local_ref]) { Ok(value) => value.trim().to_string(), Err(_) => return false, }; let remote_sha = match git_capture_in(repo_root, &["rev-parse", &remote_ref]) { Ok(value) => value.trim().to_string(), Err(_) => return false, }; !local_sha.is_empty() && local_sha == remote_sha } fn jj_stash_commits(repo_root: &Path, current: &str, dest: &str) -> Result<String> { let ts = Utc::now().format("%Y%m%d-%H%M%S").to_string(); let stash_name = format!("f-sync-stash/{}/{}", current, ts); let source_revset = jj_branch_sync_source_rev(repo_root, current); jj_run_in( repo_root, &["bookmark", "create", &stash_name, "-r", &source_revset], )?; jj_run_in(repo_root, &["bookmark", "set", current, "-r", dest])?; jj_run_in(repo_root, &["edit", current])?; Ok(stash_name) } /// Check if a rebase is in progress. fn is_rebase_in_progress() -> bool { let git_dir = git_capture(&["rev-parse", "--git-dir"]).unwrap_or_else(|_| ".git".to_string()); let git_dir = git_dir.trim(); std::path::Path::new(&format!("{}/rebase-merge", git_dir)).exists() || std::path::Path::new(&format!("{}/rebase-apply", git_dir)).exists() } /// Check if a merge is in progress. fn is_merge_in_progress() -> bool { let git_dir = git_capture(&["rev-parse", "--git-dir"]).unwrap_or_else(|_| ".git".to_string()); let git_dir = git_dir.trim(); std::path::Path::new(&format!("{}/MERGE_HEAD", git_dir)).exists() } /// Read a single keypress (y/n) without waiting for Enter. fn read_yes_no() -> Result<bool> { terminal::enable_raw_mode()?; let result = loop { if event::poll(std::time::Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => break Ok(true), KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Enter | KeyCode::Esc => { break Ok(false); } _ => {} } } } } }; terminal::disable_raw_mode()?; println!(); // Move to next line after keypress result } /// Prompt user for rebase action. fn prompt_for_rebase_action() -> Result<bool> { let conflicts = git_capture(&["diff", "--name-only", "--diff-filter=U"]).unwrap_or_default(); let conflicted_files: Vec<&str> = conflicts.lines().filter(|l| !l.is_empty()).collect(); if !conflicted_files.is_empty() { println!("\n Conflicted files:"); for file in &conflicted_files { println!(" - {}", file); } println!(); } print!(" Try auto-fix with Claude Opus? [y/N] "); std::io::Write::flush(&mut std::io::stdout())?; read_yes_no() } /// Try to resolve rebase conflicts and continue. fn try_resolve_rebase_conflicts() -> Result<bool> { loop { // Get conflicted files let conflicts = git_capture(&["diff", "--name-only", "--diff-filter=U"])?; let conflicted_files: Vec<&str> = conflicts.lines().filter(|l| !l.is_empty()).collect(); if conflicted_files.is_empty() { // No more conflicts, try to continue rebase let result = Command::new("git") .args(["rebase", "--continue"]) .env("GIT_EDITOR", "true") // Skip commit message editing .output(); match result { Ok(out) if out.status.success() => return Ok(true), Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); // Check if rebase is complete if stderr.contains("No rebase in progress") || !is_rebase_in_progress() { return Ok(true); } // Still conflicts, continue loop } Err(_) => return Ok(false), } continue; } println!(" Resolving {} conflicted files...", conflicted_files.len()); // Try to resolve each conflict let mut all_resolved = true; for file in &conflicted_files { if !try_resolve_single_conflict(file)? { all_resolved = false; println!(" ✗ Could not resolve {}", file); } } if !all_resolved { return Ok(false); } // Stage resolved files let _ = git_run(&["add", "-A"]); // Try to continue rebase let result = Command::new("git") .args(["rebase", "--continue"]) .env("GIT_EDITOR", "true") .output(); match result { Ok(out) if out.status.success() => return Ok(true), Ok(_) => { // More conflicts from next commit, continue loop if !is_rebase_in_progress() { return Ok(true); } } Err(_) => return Ok(false), } } } /// Try to resolve a single conflicted file. fn try_resolve_single_conflict(file: &str) -> Result<bool> { let filename = file.rsplit('/').next().unwrap_or(file); // Auto-generated files - accept theirs (upstream/incoming) let auto_generated = [ "STATS.md", "stats.md", "CHANGELOG.md", "changelog.md", "package-lock.json", "yarn.lock", "bun.lock", "pnpm-lock.yaml", "Cargo.lock", "Gemfile.lock", "poetry.lock", "composer.lock", ]; if auto_generated .iter() .any(|&ag| filename.eq_ignore_ascii_case(ag)) { println!(" Auto-resolving {} (accepting theirs)", file); let _ = Command::new("git") .args(["checkout", "--theirs", file]) .output(); let _ = Command::new("git").args(["add", file]).output(); return Ok(true); } // Try Claude for code conflicts let content = std::fs::read_to_string(file).unwrap_or_default(); if content.contains("<<<<<<<") { println!(" Trying Claude Opus for {}...", file); // Load sync context if available let context = ai_context::load_command_context("sync").unwrap_or_default(); let context_section = if !context.is_empty() { format!("## Context\n\n{}\n\n", context) } else { String::new() }; let prompt = format!( "{}This file has git merge conflicts. Resolve them by keeping the best of both versions. Output ONLY the resolved file content, no explanations:\n\n{}", context_section, if content.len() > 8000 { &content[..8000] } else { &content } ); let output = sync_claude_command(&prompt).output(); if let Ok(out) = output { if out.status.success() { let resolved = String::from_utf8_lossy(&out.stdout); if !resolved.contains("<<<<<<<") && !resolved.contains(">>>>>>>") { if std::fs::write(file, resolved.as_ref()).is_ok() { let _ = Command::new("git").args(["add", file]).output(); println!(" ✓ Resolved {}", file); return Ok(true); } } } } } Ok(false) } /// Prompt user to try auto-fix for push failures. fn prompt_for_push_fix() -> Result<bool> { println!(); print!(" Try auto-fix with Claude Opus? [y/N] "); std::io::Write::flush(&mut std::io::stdout())?; read_yes_no() } /// Prompt user to try auto-fix for conflicts. fn prompt_for_auto_fix() -> Result<bool> { // Get list of conflicted files let conflicts = git_capture(&["diff", "--name-only", "--diff-filter=U"])?; let conflicted_files: Vec<&str> = conflicts.lines().filter(|l| !l.is_empty()).collect(); if conflicted_files.is_empty() { return Ok(false); } println!("\n Conflicted files:"); for file in &conflicted_files { println!(" - {}", file); } println!(); print!(" Try auto-fix with Claude Opus? [y/N] "); std::io::Write::flush(&mut std::io::stdout())?; read_yes_no() } /// Try to resolve merge conflicts automatically. fn try_resolve_conflicts() -> Result<bool> { // Get list of conflicted files let conflicts = git_capture(&["diff", "--name-only", "--diff-filter=U"])?; let conflicted_files: Vec<&str> = conflicts.lines().filter(|l| !l.is_empty()).collect(); if conflicted_files.is_empty() { return Ok(true); } println!(" Conflicted files: {}", conflicted_files.join(", ")); // Auto-generated files - accept theirs (upstream) let auto_generated = [ "STATS.md", "stats.md", "CHANGELOG.md", "changelog.md", "package-lock.json", "yarn.lock", "bun.lock", "pnpm-lock.yaml", "Cargo.lock", "Gemfile.lock", "poetry.lock", "composer.lock", ]; let mut resolved_count = 0; let mut needs_claude = Vec::new(); for file in &conflicted_files { let filename = file.rsplit('/').next().unwrap_or(file); if auto_generated .iter() .any(|&ag| filename.eq_ignore_ascii_case(ag)) { // Accept theirs for auto-generated files println!(" Auto-resolving {} (accepting upstream)", file); let _ = Command::new("git") .args(["checkout", "--theirs", file]) .output(); let _ = Command::new("git").args(["add", file]).output(); resolved_count += 1; } else { needs_claude.push(*file); } } // If all conflicts were auto-generated files, we're done if needs_claude.is_empty() { return Ok(true); } // Try Claude for remaining conflicts println!( " Trying Claude Opus for {} remaining conflicts...", needs_claude.len() ); // Load sync context once for all files let context = ai_context::load_command_context("sync").unwrap_or_default(); let context_section = if !context.is_empty() { format!("## Context\n\n{}\n\n", context) } else { String::new() }; for file in &needs_claude { let content = std::fs::read_to_string(file).unwrap_or_default(); if content.contains("<<<<<<<") { let prompt = format!( "{}This file has git merge conflicts. Resolve them by keeping the best of both versions. Output ONLY the resolved file content, no explanations:\n\n{}", context_section, if content.len() > 8000 { &content[..8000] } else { &content } ); let output = sync_claude_command(&prompt).output(); if let Ok(out) = output { if out.status.success() { let resolved = String::from_utf8_lossy(&out.stdout); // Only use if it doesn't contain conflict markers if !resolved.contains("<<<<<<<") && !resolved.contains(">>>>>>>") { if std::fs::write(file, resolved.as_ref()).is_ok() { let _ = Command::new("git").args(["add", file]).output(); resolved_count += 1; println!(" ✓ Resolved {}", file); continue; } } } } println!(" ✗ Could not resolve {}", file); } } Ok(resolved_count == conflicted_files.len()) } fn restore_stash(repo_root: &Path, stashed: bool) { if stashed { println!("==> Restoring stashed changes..."); let output = Command::new("git") .current_dir(repo_root) .args(["stash", "pop"]) .output(); match output { Ok(out) if out.status.success() => {} Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}\n{}", stdout, stderr).to_lowercase(); if stash_pop_untracked_conflict(&combined) { match drop_stash_if_untracked_restored(repo_root) { Ok(true) => { println!( " ✓ Kept local untracked files and dropped redundant auto-stash" ); return; } Ok(false) => {} Err(err) => { eprintln!("warning: stash cleanup failed: {}", err); } } } eprintln!( "warning: failed to restore stash automatically: git stash pop failed\nRun `git stash list` and restore manually if needed." ); } Err(err) => { eprintln!( "warning: failed to restore stash automatically: {}\nRun `git stash list` and restore manually if needed.", err ); } } } } fn stash_pop_untracked_conflict(output: &str) -> bool { output.contains("could not restore untracked files from stash") || (output.contains("already exists, no checkout") && output.contains("stash")) } fn drop_stash_if_untracked_restored(repo_root: &Path) -> Result<bool> { if git_capture_in(repo_root, &["rev-parse", "--verify", "stash@{0}"]).is_err() { return Ok(false); } let has_untracked_parent = git_capture_in(repo_root, &["rev-parse", "--verify", "stash@{0}^3"]); if has_untracked_parent.is_err() { return Ok(false); } let files = git_capture_in(repo_root, &["ls-tree", "-r", "--name-only", "stash@{0}^3"]) .unwrap_or_default(); let untracked_paths: Vec<String> = files .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(|line| line.to_string()) .collect(); if untracked_paths.is_empty() { return Ok(false); } let all_present = untracked_paths .iter() .all(|path| repo_root.join(path).exists()); if !all_present { return Ok(false); } git_run_in(repo_root, &["stash", "drop", "stash@{0}"])?; Ok(true) } /// If fork-push is enabled in config, resolve the target remote name, owner, and fork repo name. /// /// Returns `Some((remote_name, owner, fork_repo_name))` when fork push should be used. fn resolve_fork_push_target(repo_root: &Path) -> Option<(String, String, String)> { // Check local config first, then global. let cfg = { let local = repo_root.join("flow.toml"); if local.exists() { config::load(&local).ok() } else { None } .or_else(|| { let global = config::default_config_path(); if global.exists() { config::load(&global).ok() } else { None } }) }; let git_cfg = cfg.as_ref().and_then(|c| c.git.as_ref()); if git_cfg.map(|g| g.fork_push.unwrap_or(false)) != Some(true) { return None; } let git_cfg = git_cfg.unwrap(); let owner = push::resolve_fork_owner(git_cfg.fork_push_owner.as_deref()).ok()?; let suffix = git_cfg.fork_push_suffix.as_deref().unwrap_or("-i"); // Derive base repo name from upstream or origin URL. let upstream_url = git_capture_in(repo_root, &["remote", "get-url", "upstream"]) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let origin_url = git_capture_in(repo_root, &["remote", "get-url", "origin"]) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let base_name = push::derive_repo_name(repo_root, upstream_url.as_deref(), origin_url.as_deref()).ok()?; let fork_repo = format!("{}{}", base_name, suffix); let remote_name = format!("fork{}", suffix); Some((remote_name, owner, fork_repo)) } /// Normalize a git URL for comparison (handle ssh vs https, trailing .git). fn normalize_git_url(url: &str) -> String { let url = url.trim(); // Convert SSH to HTTPS format for comparison let url = if url.starts_with("git@github.com:") { url.replace("git@github.com:", "github.com/") } else if url.starts_with("https://github.com/") { url.replace("https://github.com/", "github.com/") } else { url.to_string() }; // Remove trailing .git url.trim_end_matches(".git").to_lowercase() } #[derive(Default)] struct GitCaptureCacheState { depth: usize, entries: HashMap<String, String>, } thread_local! { static GIT_CAPTURE_CACHE: RefCell<GitCaptureCacheState> = RefCell::new(GitCaptureCacheState::default()); } struct GitCaptureCacheScope; impl GitCaptureCacheScope { fn begin() -> Self { GIT_CAPTURE_CACHE.with(|state| { let mut state = state.borrow_mut(); if state.depth == 0 { state.entries.clear(); } state.depth += 1; }); Self } } impl Drop for GitCaptureCacheScope { fn drop(&mut self) { GIT_CAPTURE_CACHE.with(|state| { let mut state = state.borrow_mut(); state.depth = state.depth.saturating_sub(1); if state.depth == 0 { state.entries.clear(); } }); } } fn git_capture_cacheable(args: &[&str]) -> bool { args == ["rev-parse", "--show-toplevel"] || args == ["rev-parse", "--git-dir"] || (args.len() == 3 && args[0] == "remote" && args[1] == "get-url") } fn git_capture_cache_key(repo_root: Option<&Path>, args: &[&str]) -> Option<String> { if !git_capture_cacheable(args) { return None; } let cwd = repo_root .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); Some(format!("{cwd}|{}", args.join("\x1f"))) } fn git_capture_cached_lookup(key: &str) -> Option<String> { GIT_CAPTURE_CACHE.with(|state| { let state = state.borrow(); if state.depth == 0 { return None; } state.entries.get(key).cloned() }) } fn git_capture_cached_store(key: String, value: String) { GIT_CAPTURE_CACHE.with(|state| { let mut state = state.borrow_mut(); if state.depth > 0 { state.entries.insert(key, value); } }); } /// Run a git command and capture stdout. fn git_capture(args: &[&str]) -> Result<String> { if let Some(key) = git_capture_cache_key(None, args) { if let Some(cached) = git_capture_cached_lookup(&key) { return Ok(cached); } } let output = Command::new("git") .args(args) .output() .context("failed to run git")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git {} failed: {}", args.join(" "), stderr.trim()); } let out = String::from_utf8_lossy(&output.stdout).to_string(); if let Some(key) = git_capture_cache_key(None, args) { git_capture_cached_store(key, out.clone()); } Ok(out) } /// Run a git command in a specific repository and capture stdout. fn git_capture_in(repo_root: &Path, args: &[&str]) -> Result<String> { if let Some(key) = git_capture_cache_key(Some(repo_root), args) { if let Some(cached) = git_capture_cached_lookup(&key) { return Ok(cached); } } let output = Command::new("git") .current_dir(repo_root) .args(args) .output() .context("failed to run git")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git {} failed: {}", args.join(" "), stderr.trim()); } let out = String::from_utf8_lossy(&output.stdout).to_string(); if let Some(key) = git_capture_cache_key(Some(repo_root), args) { git_capture_cached_store(key, out.clone()); } Ok(out) } /// Run a git command with inherited stdio. fn git_run(args: &[&str]) -> Result<()> { let status = Command::new("git") .args(args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run git")?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } /// Run a git command in a specific repository with inherited stdio. fn git_run_in(repo_root: &Path, args: &[&str]) -> Result<()> { let status = Command::new("git") .current_dir(repo_root) .args(args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run git")?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } /// Push to a remote with optional auto-fix on failure. fn push_with_autofix(branch: &str, remote: &str, auto_fix: bool, max_attempts: u32) -> Result<()> { let mut attempts = 0; loop { // Try push and capture output let output = Command::new("git") .args(["push", remote, branch]) .output() .context("failed to run git push")?; if output.status.success() { return Ok(()); } attempts += 1; let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let combined = format!("{}\n{}", stdout, stderr); // Check if this looks like a pre-push hook failure let is_hook_failure = combined.contains("pre-push") || combined.contains("husky") || combined.contains("typecheck") || combined.contains("error TS") || combined.contains("eslint") || combined.contains("error:") || combined.contains("failed to push"); // Prompt user if not already in auto-fix mode let should_fix = if auto_fix { true } else if is_hook_failure && attempts == 1 { println!("{}", combined); prompt_for_push_fix()? } else { false }; if !should_fix || attempts > max_attempts { if !should_fix { println!("{}", combined); } bail!("git push {} {} failed", remote, branch); } println!( "\n==> Push failed (attempt {}/{}), attempting auto-fix with Claude Opus...", attempts, max_attempts ); // Run Claude to fix the errors (fallback to opencode glm if Claude fails) let mut fixed = try_claude_fix(&combined)?; if !fixed { println!(" Claude fix failed; trying opencode glm..."); fixed = try_opencode_fix(&combined)?; } if !fixed { println!("{}", combined); bail!("Auto-fix failed. Run manually:\n claude 'fix these errors: ...'"); } // Stage and commit the fix let status = git_capture(&["status", "--porcelain"])?; if !status.trim().is_empty() { println!("==> Committing auto-fix..."); let _ = git_run(&["add", "-A"]); let commit_msg = format!("fix: auto-fix sync errors (attempt {})", attempts); let _ = Command::new("git") .args(["commit", "-m", &commit_msg, "--no-verify"]) .output(); } println!("==> Retrying push..."); } } /// Push to a remote with --force-with-lease, with optional auto-fix on failure. fn push_with_autofix_force( branch: &str, remote: &str, auto_fix: bool, max_attempts: u32, ) -> Result<()> { let mut attempts = 0; loop { let output = Command::new("git") .args(["push", "--force-with-lease", remote, branch]) .output() .context("failed to run git push --force-with-lease")?; if output.status.success() { return Ok(()); } attempts += 1; let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let combined = format!("{}\n{}", stdout, stderr); let is_hook_failure = combined.contains("pre-push") || combined.contains("husky") || combined.contains("typecheck") || combined.contains("error TS") || combined.contains("eslint") || combined.contains("error:") || combined.contains("failed to push"); let should_fix = if auto_fix { true } else if is_hook_failure && attempts == 1 { println!("{}", combined); prompt_for_push_fix()? } else { false }; if !should_fix || attempts > max_attempts { if !should_fix { println!("{}", combined); } bail!("git push --force-with-lease {} {} failed", remote, branch); } println!( "\n==> Push failed (attempt {}/{}), attempting auto-fix with Claude Opus...", attempts, max_attempts ); let mut fixed = try_claude_fix(&combined)?; if !fixed { println!(" Claude fix failed; trying opencode glm..."); fixed = try_opencode_fix(&combined)?; } if fixed { println!(" Changes applied. Retrying push..."); } else { bail!("auto-fix failed; push still failing"); } } } /// Try to fix errors using Claude CLI. fn try_claude_fix(error_output: &str) -> Result<bool> { // Check if claude is available let claude_check = Command::new("which").arg("claude").output(); if claude_check.is_err() || !claude_check.unwrap().status.success() { println!(" Claude CLI not found. Install with: npm i -g @anthropic-ai/claude-code"); return Ok(false); } let prompt = build_fix_prompt(error_output); // Run claude with the fix prompt let status = sync_claude_command(&prompt) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run claude")?; Ok(status.success()) } fn try_opencode_fix(error_output: &str) -> Result<bool> { let opencode_check = Command::new("which").arg("opencode").output(); if opencode_check.is_err() || !opencode_check.unwrap().status.success() { println!(" opencode CLI not found. Install with: npm i -g opencode"); return Ok(false); } let prompt = build_fix_prompt(error_output); let mut child = Command::new("opencode") .args(["run", "-m", "opencode/glm-4.7-free", "-"]) .stdin(Stdio::piped()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() .context("failed to run opencode")?; if let Some(stdin) = child.stdin.as_mut() { use std::io::Write; stdin .write_all(prompt.as_bytes()) .context("failed to write opencode prompt")?; } let status = child.wait().context("failed to wait on opencode")?; Ok(status.success()) } fn build_fix_prompt(error_output: &str) -> String { let excerpt = if error_output.len() > 4000 { &error_output[error_output.len() - 4000..] } else { error_output }; format!( "Fix these errors so the code compiles/passes checks. Make minimal changes. Do not explain, just fix:\n\n{}", excerpt ) } /// Try to create the origin repo on GitHub if it doesn't exist. fn try_create_origin_repo() -> Result<bool> { let origin_url = match git_capture(&["remote", "get-url", "origin"]) { Ok(url) => url.trim().to_string(), Err(_) => return Ok(false), }; let repo_path = if origin_url.starts_with("git@github.com:") { origin_url .strip_prefix("git@github.com:") .and_then(|s| s.strip_suffix(".git").or(Some(s))) } else if origin_url.contains("github.com/") { origin_url .split("github.com/") .nth(1) .and_then(|s| s.strip_suffix(".git").or(Some(s))) } else { None }; let Some(repo_path) = repo_path else { println!("Cannot parse origin URL for auto-creation: {}", origin_url); return Ok(false); }; println!("\nOrigin repo doesn't exist. Creating: {}", repo_path); let status = Command::new("gh") .args(["repo", "create", repo_path, "--private", "--source=."]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status(); match status { Ok(s) if s.success() => { println!("✓ Created GitHub repo: {}", repo_path); Ok(true) } Ok(_) => { println!("Failed to create repo. Is `gh` installed and authenticated?"); Ok(false) } Err(e) => { println!("Failed to run gh CLI: {}", e); Ok(false) } } } fn sync_log_dirs() -> Vec<PathBuf> { let mut dirs = Vec::new(); if let Some(home) = dirs::home_dir() { dirs.push(home.join("code").join("org").join("linsa").join("base")); dirs.push(home.join("repos").join("garden-co").join("jazz2")); dirs.push(home.join("code").join("org").join("1f").join("jazz2")); } dirs } fn write_sync_snapshot(snapshot: &SyncSnapshot) -> Result<()> { let mut value = serde_json::to_value(snapshot)?; secret_redact::redact_json_value(&mut value); let payload = serde_json::to_string(&value)?; for base in sync_log_dirs() { let target_dir = base.join("sync"); if !target_dir.exists() { if let Err(err) = fs::create_dir_all(&target_dir) { eprintln!( "warn: unable to create sync log dir {}: {}", target_dir.display(), err ); continue; } } let log_path = target_dir.join("flow-sync.jsonl"); let mut file = fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) .with_context(|| format!("open sync log {}", log_path.display()))?; writeln!(file, "{}", payload)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_branch_merge_ref_strips_heads_prefix() { assert_eq!( parse_branch_merge_ref("refs/heads/feature/socket-command"), Some("feature/socket-command".to_string()) ); assert_eq!(parse_branch_merge_ref("main"), Some("main".to_string())); } #[test] fn parse_tracking_ref_parses_remote_and_branch() { assert_eq!( parse_tracking_ref("fork/socket-command"), Some(("fork".to_string(), "socket-command".to_string())) ); assert_eq!( parse_tracking_ref("origin/feature/latency/tune"), Some(("origin".to_string(), "feature/latency/tune".to_string())) ); } #[test] fn parse_tracking_ref_rejects_invalid_values() { assert_eq!(parse_tracking_ref(""), None); assert_eq!(parse_tracking_ref("origin"), None); assert_eq!(parse_tracking_ref("/main"), None); assert_eq!(parse_tracking_ref("origin/"), None); } #[test] fn jj_local_bookmark_revset_uses_exact_mutable_selector() { assert_eq!( jj_local_bookmark_revset("main"), r#"bookmarks(exact:"main") & mutable()"# ); assert_eq!( jj_local_bookmark_revset("feature/sync-fix"), r#"bookmarks(exact:"feature/sync-fix") & mutable()"# ); } #[test] fn normalize_sync_commit_line_fills_missing_description() { assert_eq!( normalize_sync_commit_line("abc12345", "Fix sync output"), "abc12345 Fix sync output" ); assert_eq!( normalize_sync_commit_line("abc12345", ""), "abc12345 (no description)" ); } #[test] fn build_synced_commit_list_dedupes_hash_width_variants() { let cmd = SyncCommand { rebase: false, push: false, no_push: true, stash: false, stash_commits: false, allow_queue: false, create_repo: false, fix: false, no_fix: true, max_fix_attempts: 0, allow_review_issues: false, compact: false, }; let mut recorder = SyncRecorder::new(&cmd).expect("sync recorder"); recorder.add_remote_update(SyncRemoteUpdate { remote: "origin".to_string(), branch: "main".to_string(), before_tip: Some("before".to_string()), after_tip: "after".to_string(), commit_count: 1, commits: vec!["8e258eb3f feat: persist latest model".to_string()], }); recorder.add_remote_update(SyncRemoteUpdate { remote: "synced:upstream".to_string(), branch: "main".to_string(), before_tip: Some("before".to_string()), after_tip: "after".to_string(), commit_count: 1, commits: vec!["8e258eb3 feat: persist latest model".to_string()], }); assert_eq!( build_synced_commit_list(&recorder), vec!["8e258eb3f feat: persist latest model".to_string()] ); } #[test] fn sync_claude_command_uses_latest_opus_alias() { let cmd = sync_claude_command("resolve this"); let args: Vec<String> = cmd .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect(); assert_eq!( args, vec![ "--print", "--model", "opus", "--dangerously-skip-permissions", "resolve this", ] ); } } ================================================ FILE: src/task_failure_agents.rs ================================================ use std::collections::HashMap; use std::fs; use std::io::IsTerminal; use std::path::Path; use std::process::{Command, Stdio}; use anyhow::Result; use serde::Deserialize; use crate::config; #[derive(Debug, Clone)] struct TaskFailureSettings { enabled: bool, tool: String, max_lines: usize, max_chars: usize, max_agents: usize, } impl Default for TaskFailureSettings { fn default() -> Self { Self { enabled: false, tool: "hive".to_string(), max_lines: 80, max_chars: 8000, max_agents: 2, } } } #[derive(Debug, Deserialize)] struct HiveConfig { agents: Option<HashMap<String, HiveAgentSpec>>, } #[derive(Debug, Deserialize)] struct HiveAgentSpec { #[serde(rename = "matchedOn")] matched_on: Option<Vec<String>>, } fn load_settings() -> TaskFailureSettings { let mut settings = TaskFailureSettings::default(); if let Some(ts_config) = config::load_ts_config() { if let Some(flow) = ts_config.flow { if let Some(task_failure) = flow.task_failure_agents { if let Some(enabled) = task_failure.enabled { settings.enabled = enabled; } if let Some(tool) = task_failure.tool { if !tool.trim().is_empty() { settings.tool = tool; } } if let Some(max_lines) = task_failure.max_lines { settings.max_lines = max_lines.max(1); } if let Some(max_chars) = task_failure.max_chars { settings.max_chars = max_chars.max(100); } if let Some(max_agents) = task_failure.max_agents { settings.max_agents = max_agents.max(1); } } } } settings } fn load_hive_config() -> Option<HiveConfig> { let path = dirs::home_dir()?.join(".hive/config.json"); let content = fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() } fn truncate_output(output: &str, max_lines: usize, max_chars: usize) -> String { let mut lines: Vec<&str> = output.lines().collect(); if lines.len() > max_lines { lines = lines[lines.len().saturating_sub(max_lines)..].to_vec(); } let mut joined = lines.join("\n"); if joined.len() > max_chars { let start = joined.len().saturating_sub(max_chars); joined = format!("...{}", &joined[start..]); } joined } fn matches_agent(haystack: &str, spec: &HiveAgentSpec) -> bool { let Some(terms) = &spec.matched_on else { return false; }; terms.iter().any(|term| { let needle = term.to_lowercase(); !needle.is_empty() && haystack.contains(&needle) }) } fn run_hive_agent(agent: &str, prompt: &str) -> Result<()> { let status = Command::new("hive") .arg("agent") .arg(agent) .arg(prompt) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status()?; if !status.success() { eprintln!( "⚠ hive agent '{}' exited with status {:?}", agent, status.code() ); } Ok(()) } pub fn maybe_run_task_failure_agents( task_name: &str, command: &str, workdir: &Path, output: &str, status: Option<i32>, ) { if let Ok(value) = std::env::var("FLOW_TASK_FAILURE_AGENTS") { let lowered = value.trim().to_lowercase(); if lowered == "0" || lowered == "false" || lowered == "off" { return; } } if std::env::var("FLOW_DISABLE_TASK_FAILURE_AGENTS").is_ok() { return; } let settings = load_settings(); if !settings.enabled { return; } if settings.tool != "hive" { eprintln!( "⚠ task-failure agents: unsupported tool '{}'", settings.tool ); return; } if !std::io::stdin().is_terminal() { return; } if which::which("hive").is_err() { eprintln!("⚠ task-failure agents: hive not found on PATH"); return; } let Some(config) = load_hive_config() else { eprintln!("⚠ task-failure agents: ~/.hive/config.json not found"); return; }; let truncated = truncate_output(output, settings.max_lines, settings.max_chars); let mut haystack = String::new(); haystack.push_str(&format!( "task: {}\ncommand: {}\nstatus: {}\nworkdir: {}\noutput:\n{}", task_name, command, status.unwrap_or(-1), workdir.display(), truncated )); let haystack_lower = haystack.to_lowercase(); let mut matches: Vec<String> = Vec::new(); if let Some(agents) = config.agents { for (name, spec) in agents { if matches_agent(&haystack_lower, &spec) { matches.push(name); } } } if matches.is_empty() { return; } matches.truncate(settings.max_agents); for agent in matches { println!("Running agent '{}' for task failure...", agent); if let Err(err) = run_hive_agent(&agent, &haystack) { eprintln!("⚠ failed to run hive agent '{}': {}", agent, err); } } } ================================================ FILE: src/task_match.rs ================================================ //! Match user query to a task using LM Studio. use std::io::{self, Write}; use std::path::PathBuf; use anyhow::{Context, Result, bail}; use crate::cli::Cli; use crate::{ cli::TaskRunOpts, config, discover::DiscoveredTask, lmstudio, project_snapshot::ProjectSnapshot, tasks, }; use clap::{CommandFactory, Parser}; /// Options for the match command. #[derive(Debug, Clone)] pub struct MatchOpts { /// The user's query as separate arguments (preserves quoting from shell). pub args: Vec<String>, /// LM Studio model to use. pub model: Option<String>, /// LM Studio API port. pub port: Option<u16>, /// Whether to actually run the matched task. pub execute: bool, } /// Result of matching a query to a task. #[derive(Debug)] pub struct MatchResult { pub task_name: String, pub config_path: PathBuf, pub relative_dir: String, } // Built-in commands that can be run directly if no task matches const BUILTIN_COMMANDS: &[(&str, &[&str])] = &[("commit", &["commit", "c"])]; fn cli_subcommands() -> Vec<String> { let mut names = Vec::new(); let cmd = Cli::command(); for sub in cmd.get_subcommands() { names.push(sub.get_name().to_string()); for alias in sub.get_all_aliases() { names.push(alias.to_string()); } } names.extend(["help", "-h", "--help"].iter().map(|s| s.to_string())); names } fn run_builtin(name: &str, execute: bool) -> Result<()> { match name { "commit" => { println!("Running: commit"); if execute { let queue = crate::commit::resolve_commit_queue_mode(false, false); let push = true; crate::commit::run(push, queue, false, &[])?; } } _ => bail!("Unknown built-in: {}", name), } Ok(()) } fn find_builtin(query: &str) -> Option<&'static str> { let q = query.trim().to_lowercase(); for (name, aliases) in BUILTIN_COMMANDS { if aliases.iter().any(|a| *a == q) { return Some(name); } } None } /// Check if the first arg is a CLI subcommand that needs pass-through fn is_cli_subcommand(args: &[String]) -> bool { let Some(first) = args.first() else { return false; }; let first_lower = first.to_ascii_lowercase(); cli_subcommands() .iter() .any(|cmd| cmd.eq_ignore_ascii_case(&first_lower)) } fn should_passthrough_cli(args: &[String]) -> bool { if args.is_empty() { return false; } if args[0].eq_ignore_ascii_case("match") { return false; } let mut argv = Vec::with_capacity(args.len() + 1); argv.push("f".to_string()); argv.extend(args.iter().cloned()); Cli::try_parse_from(argv).is_ok() || is_cli_subcommand(args) } /// Re-invoke the CLI with the original arguments (bypassing match) fn passthrough_to_cli(args: &[String]) -> Result<()> { use std::process::Command; let exe = std::env::current_exe().context("failed to get current executable")?; let status = Command::new(&exe) .args(args) .status() .with_context(|| format!("failed to run: {} {}", exe.display(), args.join(" ")))?; if !status.success() { std::process::exit(status.code().unwrap_or(1)); } Ok(()) } /// Match a user query to a task and optionally execute it. pub fn run(opts: MatchOpts) -> Result<()> { let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let snapshot = ProjectSnapshot::from_root_tasks_only(&root)?; run_with_tasks(opts, snapshot.discovery.tasks, true) } /// Match a user query to a global task and optionally execute it. pub fn run_global(opts: MatchOpts) -> Result<()> { let config_path = config::default_config_path(); if !config_path.exists() { bail!("global flow config not found at {}", config_path.display()); } let cfg = config::load(&config_path).with_context(|| { format!( "failed to load global flow config at {}", config_path.display() ) })?; let tasks = cfg .tasks .iter() .map(|task| DiscoveredTask { task: task.clone(), config_path: config_path.clone(), relative_dir: "global".to_string(), depth: 0, scope: "global".to_string(), scope_aliases: vec!["global".to_string()], }) .collect(); run_with_tasks(opts, tasks, false) } fn run_with_tasks( opts: MatchOpts, tasks: Vec<DiscoveredTask>, allow_passthrough: bool, ) -> Result<()> { // Check if this is a CLI subcommand that should bypass matching if allow_passthrough && should_passthrough_cli(&opts.args) { return passthrough_to_cli(&opts.args); } // Join args for display/LLM purposes (but task execution uses the preserved args) let query_display = opts.args.join(" "); // Try direct match first (exact name, shortcut, or abbreviation) - no LLM needed let (task_name, task_args, was_direct_match) = if let Some(direct) = try_direct_match(&opts.args, &tasks) { (direct.task_name, direct.args, true) } else if let Some(builtin) = find_builtin(&query_display) { // No task match, but matches a built-in command return run_builtin(builtin, opts.execute); } else if tasks.is_empty() { if allow_passthrough { // No tasks and no built-in match: behave like `f <args>` return passthrough_to_cli(&opts.args); } bail!("No global tasks available to match."); } else if allow_passthrough && opts.args.len() == 1 { // Single-token queries should behave like `f <arg>` if no direct match. return passthrough_to_cli(&opts.args); } else { // No direct match, use LM Studio let prompt = build_matching_prompt(&query_display, &tasks); // Query LM Studio (will fail with clear error if not running) let response = match lmstudio::quick_prompt(&prompt, opts.model.as_deref(), opts.port) { Ok(r) if !r.trim().is_empty() => r, Ok(_) => { // Empty response - check for built-in before failing if let Some(builtin) = find_builtin(&query_display) { return run_builtin(builtin, opts.execute); } let task_list: Vec<_> = tasks.iter().map(|t| t.task.name.as_str()).collect(); bail!( "No match for '{}'. LM Studio returned empty response.\n\nAvailable tasks:\n {}", query_display, task_list.join("\n ") ); } Err(e) => { // LM Studio error - fall back to built-in if available if let Some(builtin) = find_builtin(&query_display) { return run_builtin(builtin, opts.execute); } let task_list: Vec<_> = tasks.iter().map(|t| t.task.name.as_str()).collect(); bail!( "No direct match for '{}'. LM Studio error: {}\n\nAvailable tasks:\n {}", query_display, e, task_list.join("\n ") ); } }; // Parse the response to get the task name (no args for LLM matches) (extract_task_name(&response, &tasks)?, Vec::new(), false) }; // Find the matched task let matched = tasks .iter() .find(|t| t.task.name.eq_ignore_ascii_case(&task_name)) .ok_or_else(|| anyhow::anyhow!("LM Studio returned unknown task: {}", task_name))?; // Show what was matched if matched.relative_dir.is_empty() { println!("Matched: {} – {}", matched.task.name, matched.task.command); } else { println!( "Matched: {} ({}) – {}", matched.task.name, matched.relative_dir, matched.task.command ); } if opts.execute { // Check if confirmation is needed (only for LLM matches on tasks with confirm_on_match) let needs_confirm = !was_direct_match && matched.task.confirm_on_match; if needs_confirm { print!("Press Enter to confirm, Ctrl+C to cancel: "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; } // Execute the matched task let run_opts = TaskRunOpts { config: matched.config_path.clone(), delegate_to_hub: false, hub_host: "127.0.0.1".parse().unwrap(), hub_port: 9050, name: matched.task.name.clone(), args: task_args.clone(), }; tasks::run(run_opts)?; } Ok(()) } /// Normalize a string by removing hyphens, underscores, and lowercasing fn normalize_name(s: &str) -> String { s.chars() .filter(|c| *c != '-' && *c != '_') .collect::<String>() .to_ascii_lowercase() } /// Result of a direct match attempt - includes task name and any extra args struct DirectMatchResult { task_name: String, args: Vec<String>, } /// Try to match query directly to a task name, shortcut, or abbreviation. /// Returns the task name and any remaining arguments. fn try_direct_match(args: &[String], tasks: &[DiscoveredTask]) -> Option<DirectMatchResult> { if args.is_empty() { return None; } let first = args[0].trim(); let rest: Vec<String> = args[1..].to_vec(); // Exact name match (case-insensitive) if let Some(task) = tasks .iter() .find(|t| t.task.name.eq_ignore_ascii_case(first)) { return Some(DirectMatchResult { task_name: task.task.name.clone(), args: rest, }); } // Shortcut match if let Some(task) = tasks.iter().find(|t| { t.task .shortcuts .iter() .any(|s| s.eq_ignore_ascii_case(first)) }) { return Some(DirectMatchResult { task_name: task.task.name.clone(), args: rest, }); } // Normalized match (ignoring hyphens/underscores, only if unambiguous) let normalized_query = normalize_name(first); let mut normalized_matches: Vec<_> = tasks .iter() .filter(|t| normalize_name(&t.task.name) == normalized_query) .collect(); if normalized_matches.len() == 1 { return Some(DirectMatchResult { task_name: normalized_matches.remove(0).task.name.clone(), args: rest, }); } // Abbreviation match (only if unambiguous) let needle = first.to_ascii_lowercase(); if needle.len() >= 2 { let mut matches = tasks.iter().filter(|t| { generate_abbreviation(&t.task.name) .map(|abbr| abbr == needle) .unwrap_or(false) }); if let Some(first_match) = matches.next() { if matches.next().is_none() { return Some(DirectMatchResult { task_name: first_match.task.name.clone(), args: rest, }); } } } // Prefix match (only if unambiguous) - e.g., "prime" matches "primes" if needle.len() >= 2 { let mut prefix_matches: Vec<_> = tasks .iter() .filter(|t| t.task.name.to_ascii_lowercase().starts_with(&needle)) .collect(); if prefix_matches.len() == 1 { return Some(DirectMatchResult { task_name: prefix_matches.remove(0).task.name.clone(), args: rest, }); } } None } fn generate_abbreviation(name: &str) -> Option<String> { let mut abbr = String::new(); let mut new_segment = true; for ch in name.chars() { if ch.is_ascii_alphanumeric() { if new_segment { abbr.push(ch.to_ascii_lowercase()); new_segment = false; } } else { new_segment = true; } } if abbr.len() >= 2 { Some(abbr) } else { None } } fn build_matching_prompt(query: &str, tasks: &[DiscoveredTask]) -> String { let mut prompt = String::new(); prompt.push_str( "You are a task matcher. Given a user query, select the most appropriate task from the list below.\n\n", ); prompt.push_str("Available tasks:\n"); for task in tasks { let location = if task.relative_dir.is_empty() { String::new() } else { format!(" (in {})", task.relative_dir) }; let desc = task .task .description .as_deref() .unwrap_or(&task.task.command); prompt.push_str(&format!("- {}{}: {}\n", task.task.name, location, desc)); } prompt.push_str("\nRespond with ONLY the exact task name, nothing else. No explanation.\n"); prompt.push_str(&format!("\nUser query: {}\n", query)); prompt.push_str("\nTask name:"); prompt } fn extract_task_name(response: &str, tasks: &[DiscoveredTask]) -> Result<String> { let response = response.trim(); // Try exact match first for task in tasks { if task.task.name.eq_ignore_ascii_case(response) { return Ok(task.task.name.clone()); } } // Try to find a task name within the response for task in tasks { if response .to_lowercase() .contains(&task.task.name.to_lowercase()) { return Ok(task.task.name.clone()); } } // Clean up common LLM artifacts let cleaned = response .trim_start_matches(|c: char| !c.is_alphanumeric()) .trim_end_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') .to_string(); for task in tasks { if task.task.name.eq_ignore_ascii_case(&cleaned) { return Ok(task.task.name.clone()); } } bail!( "Could not parse task name from LM response: '{}'\nAvailable tasks: {}", response, tasks .iter() .map(|t| t.task.name.as_str()) .collect::<Vec<_>>() .join(", ") ) } #[cfg(test)] mod tests { use super::*; use crate::config::TaskConfig; fn make_discovered(name: &str, desc: Option<&str>) -> DiscoveredTask { DiscoveredTask { task: TaskConfig { name: name.to_string(), command: format!("echo {}", name), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: desc.map(|s| s.to_string()), shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, config_path: PathBuf::from("flow.toml"), relative_dir: String::new(), depth: 0, scope: "root".to_string(), scope_aliases: vec!["root".to_string()], } } #[test] fn extracts_exact_task_name() { let tasks = vec![ make_discovered("build", Some("Build the project")), make_discovered("test", Some("Run tests")), ]; assert_eq!(extract_task_name("build", &tasks).unwrap(), "build"); assert_eq!(extract_task_name("BUILD", &tasks).unwrap(), "build"); assert_eq!(extract_task_name(" test ", &tasks).unwrap(), "test"); } #[test] fn extracts_task_name_from_response() { let tasks = vec![ make_discovered("build", None), make_discovered("deploy-prod", None), ]; assert_eq!( extract_task_name("The task is: build", &tasks).unwrap(), "build" ); assert_eq!( extract_task_name("deploy-prod.", &tasks).unwrap(), "deploy-prod" ); } } ================================================ FILE: src/tasks.rs ================================================ use std::{ borrow::Cow, collections::{BTreeMap, HashMap, hash_map::DefaultHasher}, env, fs::{self, File, OpenOptions}, hash::{Hash, Hasher}, io::{IsTerminal, Read, Write}, net::IpAddr, path::{Path, PathBuf}, process::{Command, ExitStatus, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use serde_json::json; use shell_words; use which::which; use crate::{ ai_taskd, ai_tasks, cli::{ FastRunOpts, GlobalAction, GlobalCommand, HubAction, HubCommand, HubOpts, TaskActivateOpts, TaskRunOpts, TasksAction, TasksBuildAiOpts, TasksCommand, TasksDaemonAction, TasksDaemonCommand, TasksDupesOpts, TasksInitAiOpts, TasksListOpts, TasksOpts, TasksRunAiOpts, }, config::{self, Config, FloxInstallSpec, TaskConfig, TaskResolutionConfig}, discover, flox::{self, FloxEnv}, history::{self, InvocationRecord}, hub, init, jazz_state, project_snapshot::{self, AiTaskSnapshot, ProjectSnapshot}, projects, running::{self, RunningProcess}, secret_redact, task_failure_agents, task_match, }; /// Fire-and-forget log ingester that batches output lines and POSTs them to the /// Flow daemon's `/logs/ingest` endpoint on a background thread. struct LogIngester { tx: std::sync::mpsc::Sender<String>, } impl LogIngester { fn new(project: &str, service: &str) -> Self { let (tx, rx) = std::sync::mpsc::channel::<String>(); let project = project.to_string(); let service = service.to_string(); thread::spawn(move || { let client = match crate::http_client::blocking_with_timeout(Duration::from_secs(2)) { Ok(c) => c, Err(_) => return, }; let mut batch: Vec<serde_json::Value> = Vec::new(); let flush_interval = Duration::from_millis(500); let mut last_flush = Instant::now(); loop { match rx.recv_timeout(flush_interval) { Ok(line) => { batch.push(json!({ "project": project, "content": line, "timestamp": running::now_ms() as i64, "type": "log", "service": service, "format": "text", })); // Flush if batch is large enough or interval has passed if batch.len() >= 50 || last_flush.elapsed() >= flush_interval { let _ = client .post("http://127.0.0.1:9050/logs/ingest") .json(&batch) .send(); batch.clear(); last_flush = Instant::now(); } } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if !batch.is_empty() { let _ = client .post("http://127.0.0.1:9050/logs/ingest") .json(&batch) .send(); batch.clear(); last_flush = Instant::now(); } } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { if !batch.is_empty() { let _ = client .post("http://127.0.0.1:9050/logs/ingest") .json(&batch) .send(); } break; } } } }); Self { tx } } fn send(&self, line: &str) { let _ = self.tx.send(secret_redact::redact_text(line)); } } /// Flag set by the SIGWINCH signal handler when the terminal is resized. static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false); /// Tracks whether raw mode is currently active so the ctrlc handler can restore /// the terminal before exiting. static RAW_MODE_ACTIVE: AtomicBool = AtomicBool::new(false); #[cfg(unix)] unsafe extern "C" fn sigwinch_handler(_sig: libc::c_int) { SIGWINCH_RECEIVED.store(true, Ordering::SeqCst); } /// RAII guard that disables raw mode and clears the global flag on drop, /// ensuring the terminal is always restored even on early returns or panics. struct RawModeGuard; impl Drop for RawModeGuard { fn drop(&mut self) { RAW_MODE_ACTIVE.store(false, Ordering::SeqCst); let _ = crossterm::terminal::disable_raw_mode(); } } /// Global state for cancel cleanup handler. static CANCEL_HANDLER_SET: AtomicBool = AtomicBool::new(false); static FISHX_WARNED: AtomicBool = AtomicBool::new(false); /// Cleanup state shared with the signal handler. struct CleanupState { command: Option<String>, workdir: PathBuf, pid: Option<u32>, pgid: Option<u32>, } static CLEANUP_STATE: std::sync::OnceLock<Mutex<CleanupState>> = std::sync::OnceLock::new(); /// Run the cleanup command if one is set. fn run_cleanup() { let state = CLEANUP_STATE.get_or_init(|| { Mutex::new(CleanupState { command: None, workdir: PathBuf::from("."), pid: None, pgid: None, }) }); if let Ok(guard) = state.lock() { terminate_tracked_process(&guard); if let Some(ref cmd) = guard.command { eprintln!("\nRunning cleanup: {}", cmd); let _ = Command::new("/bin/sh") .arg("-c") .arg(cmd) .current_dir(&guard.workdir) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status(); } } } /// Set up the cleanup handler for Ctrl+C. fn setup_cancel_handler(on_cancel: Option<&str>, workdir: &Path) { let state = CLEANUP_STATE.get_or_init(|| { Mutex::new(CleanupState { command: None, workdir: PathBuf::from("."), pid: None, pgid: None, }) }); // Update the cleanup state if let Ok(mut guard) = state.lock() { guard.command = on_cancel.map(|s| s.to_string()); guard.workdir = workdir.to_path_buf(); guard.pid = None; guard.pgid = None; } // Only set up the handler once if !CANCEL_HANDLER_SET.swap(true, Ordering::SeqCst) { let _ = ctrlc::set_handler(move || { run_cleanup(); if RAW_MODE_ACTIVE.load(Ordering::SeqCst) { let _ = crossterm::terminal::disable_raw_mode(); } std::process::exit(130); // 128 + SIGINT (2) }); } } /// Clear the cleanup handler (called after task completes normally). fn clear_cancel_handler() { let state = CLEANUP_STATE.get_or_init(|| { Mutex::new(CleanupState { command: None, workdir: PathBuf::from("."), pid: None, pgid: None, }) }); if let Ok(mut guard) = state.lock() { guard.command = None; guard.pid = None; guard.pgid = None; } } fn set_cleanup_process(pid: u32, pgid: u32) { let state = CLEANUP_STATE.get_or_init(|| { Mutex::new(CleanupState { command: None, workdir: PathBuf::from("."), pid: None, pgid: None, }) }); if let Ok(mut guard) = state.lock() { guard.pid = Some(pid); guard.pgid = Some(pgid); } } fn terminate_tracked_process(state: &CleanupState) { #[cfg(unix)] { let self_pgid = running::get_pgid(std::process::id()).unwrap_or(0); if let Some(pgid) = state.pgid { if pgid != 0 && pgid != self_pgid { let _ = Command::new("kill") .arg("-TERM") .arg(format!("-{}", pgid)) .status(); return; } } if let Some(pid) = state.pid { let _ = Command::new("kill") .arg("-TERM") .arg(pid.to_string()) .status(); } } #[cfg(windows)] { if let Some(pid) = state.pid { let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .status(); } } } /// Context for registering a running task process #[derive(Debug, Clone)] pub struct TaskContext { pub task_name: String, pub command: String, pub config_path: PathBuf, pub project_root: PathBuf, pub used_flox: bool, pub project_name: Option<String>, pub log_path: Option<PathBuf>, pub interactive: bool, } /// Check if a command needs interactive mode (TTY passthrough). /// Auto-detects commands that typically require user input. fn needs_interactive_mode(command: &str) -> bool { // Check each line of the command (for multi-line scripts) for line in command.lines() { let line = line.trim(); // Skip empty lines and comments if line.is_empty() || line.starts_with('#') { continue; } // Commands that need interactive mode when they start a line let interactive_prefixes = [ "sudo ", "sudo\t", "su ", "ssh ", "docker run -it", "docker run -ti", "docker exec -it", "docker exec -ti", "kubectl exec -it", "kubectl exec -ti", ]; for prefix in &interactive_prefixes { if line.starts_with(prefix) { return true; } } // Also check if line is exactly "sudo" followed by something if line == "sudo" || line.starts_with("sudo ") { return true; } } // Check for sudo anywhere in piped/chained commands if command.contains("| sudo") || command.contains("&& sudo") || command.contains("; sudo") { return true; } // Standalone interactive commands (check first line's first word) let interactive_commands = [ "vim", "nvim", "nano", "emacs", "htop", "top", "btop", "less", "more", "psql", "mysql", "sqlite3", "node", "python", "python3", "irb", "ghci", "lazygit", "lazydocker", // Package managers can have interactive prompts (corepack, license confirmations, etc.) "pnpm", "npm", "yarn", "bun", ]; let first_line = command.lines().next().unwrap_or("").trim(); let first_word = first_line.split_whitespace().next().unwrap_or(""); let base_cmd = first_word.rsplit('/').next().unwrap_or(first_word); interactive_commands.contains(&base_cmd) } /// Handle `f tasks` command: fuzzy search history or list tasks. pub fn run_tasks_command(cmd: TasksCommand) -> Result<()> { match cmd.action { Some(TasksAction::List(opts)) => list_tasks(opts), Some(TasksAction::Dupes(opts)) => list_task_duplicates(opts), Some(TasksAction::InitAi(opts)) => init_ai_tasks(opts), Some(TasksAction::BuildAi(opts)) => build_ai_task(opts), Some(TasksAction::RunAi(opts)) => run_ai_task(opts), Some(TasksAction::Daemon(cmd)) => run_ai_task_daemon_command(cmd), None => fuzzy_search_task_history(), } } pub fn run_fast(opts: FastRunOpts) -> Result<()> { let root = project_snapshot::canonicalize_root(&opts.root)?; let selector = opts.name.trim(); if !selector.to_ascii_lowercase().starts_with("ai:") { bail!( "f fast expects an AI task selector (for example: ai:flow/dev-check), got '{}'", opts.name ); } if let Some(()) = run_via_fast_client(&root, selector, &opts.args, opts.no_cache)? { return Ok(()); } run_via_daemon_with_lazy_start(&root, selector, &opts.args, opts.no_cache) } fn run_ai_task_daemon_command(cmd: TasksDaemonCommand) -> Result<()> { match cmd.action { TasksDaemonAction::Start => ai_taskd::start(), TasksDaemonAction::Stop => ai_taskd::stop(), TasksDaemonAction::Status => ai_taskd::status(), TasksDaemonAction::Serve => ai_taskd::serve(), } } fn build_ai_task(opts: TasksBuildAiOpts) -> Result<()> { let snapshot = AiTaskSnapshot::from_root(&opts.root)?; let task = ai_tasks::select_task(&snapshot.tasks, &opts.name)?.with_context(|| { format!( "AI task '{}' not found in {}", opts.name, snapshot.root.display() ) })?; let artifact = ai_tasks::build_task_cached(task, &snapshot.root, opts.force)?; println!( "ai task cached: {}\n key: {}\n binary: {}\n rebuilt: {}", task.id, artifact.cache_key, artifact.binary_path.display(), artifact.rebuilt ); Ok(()) } fn run_ai_task(opts: TasksRunAiOpts) -> Result<()> { let root = project_snapshot::canonicalize_root(&opts.root)?; let mut policy = AiTaskExecutionPolicy::from_env(); if opts.daemon { policy.use_daemon = true; } if opts.no_cache { policy.no_cache = true; } if !execute_ai_task_by_selector(&root, &opts.name, &opts.args, &policy)? { bail!("AI task '{}' not found in {}", opts.name, root.display()); } Ok(()) } #[derive(Debug, Clone, Copy)] struct AiTaskExecutionPolicy { use_daemon: bool, no_cache: bool, } impl AiTaskExecutionPolicy { fn from_env() -> Self { let runtime = std::env::var("FLOW_AI_TASK_RUNTIME") .ok() .unwrap_or_default() .to_ascii_lowercase(); Self { use_daemon: env_flag_is_true("FLOW_AI_TASK_DAEMON"), no_cache: runtime == "moon-run" || runtime == "moon", } } } fn env_flag_is_true(name: &str) -> bool { match std::env::var(name) { Ok(raw) => matches!( raw.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ), Err(_) => false, } } fn should_prefer_fast_client(root: &Path, selector: &str) -> bool { if !env_flag_is_true("FLOW_AI_TASK_FAST_CLIENT") { return false; } if !selector.trim().to_ascii_lowercase().starts_with("ai:") { return false; } if let Ok(raw) = std::env::var("FLOW_AI_TASK_FAST_SELECTORS") && selector_matches_patterns(selector, &raw) { return true; } if let Ok(Some(task)) = ai_tasks::resolve_task_fast(root, selector) { return task.tags.iter().any(|tag| { matches!( tag.trim().to_ascii_lowercase().as_str(), "fast" | "latency" | "hot" | "hotkey" ) }); } false } fn selector_matches_patterns(selector: &str, patterns_csv: &str) -> bool { let selector = selector.trim(); for raw in patterns_csv.split(',') { let p = raw.trim(); if p.is_empty() { continue; } if p == "*" { return true; } if p.starts_with('*') && p.ends_with('*') && p.len() >= 3 { let needle = &p[1..p.len() - 1]; if selector.contains(needle) { return true; } continue; } if let Some(prefix) = p.strip_suffix('*') { if selector.starts_with(prefix) { return true; } continue; } if let Some(suffix) = p.strip_prefix('*') { if selector.ends_with(suffix) { return true; } continue; } if selector.eq_ignore_ascii_case(p) { return true; } } false } fn fast_client_binary_path(root: &Path) -> Option<PathBuf> { if let Ok(raw) = std::env::var("FLOW_AI_TASK_FAST_CLIENT_BIN") { let p = PathBuf::from(raw.trim()); if p.is_file() { return Some(p); } } if let Some(home) = dirs::home_dir() { let fai = home.join(".local").join("bin").join("fai"); if fai.is_file() { return Some(fai); } } let release_local = root.join("target").join("release").join("ai-taskd-client"); if release_local.is_file() { return Some(release_local); } let debug_local = root.join("target").join("debug").join("ai-taskd-client"); if debug_local.is_file() { return Some(debug_local); } which("ai-taskd-client").ok() } fn run_via_fast_client( root: &Path, selector: &str, args: &[String], no_cache: bool, ) -> Result<Option<()>> { let Some(bin) = fast_client_binary_path(root) else { return Ok(None); }; fn invoke( bin: &Path, root: &Path, selector: &str, args: &[String], no_cache: bool, ) -> Result<std::process::Output> { let mut cmd = Command::new(bin); cmd.arg("--root").arg(root); if no_cache { cmd.arg("--no-cache"); } cmd.arg(selector); if !args.is_empty() { cmd.arg("--"); cmd.args(args); } cmd.output().with_context(|| { format!( "failed to run fast ai client '{}' for selector '{}'", bin.display(), selector ) }) } let mut output = invoke(&bin, root, selector, args, no_cache)?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase(); let unavailable = stderr.contains("failed to connect") || stderr.contains("connection refused") || stderr.contains("no such file or directory"); if unavailable { ai_taskd::start()?; output = invoke(&bin, root, selector, args, no_cache)?; } } if !output.stdout.is_empty() { print!("{}", String::from_utf8_lossy(&output.stdout)); } if !output.stderr.is_empty() { eprint!("{}", String::from_utf8_lossy(&output.stderr)); } if output.status.success() { Ok(Some(())) } else { let code = output.status.code().unwrap_or(1); bail!("fast ai client failed for '{}': exit {}", selector, code); } } fn execute_ai_task_by_selector( root: &Path, selector: &str, args: &[String], policy: &AiTaskExecutionPolicy, ) -> Result<bool> { if policy.use_daemon { if should_prefer_fast_client(root, selector) && run_via_fast_client(root, selector, args, policy.no_cache)?.is_some() { return Ok(true); } match run_via_daemon_with_lazy_start(root, selector, args, policy.no_cache) { Ok(()) => return Ok(true), Err(error) => { let msg = format!("{error:#}").to_ascii_lowercase(); if msg.contains("not found") { return Ok(false); } return Err(error); } } } if let Some(ai_task) = ai_tasks::resolve_task_fast(root, selector)? { execute_ai_task(root, &ai_task.id, &ai_task, args, policy)?; return Ok(true); } let snapshot = AiTaskSnapshot::from_canonical_root(root.to_path_buf())?; let Some(ai_task) = ai_tasks::select_task(&snapshot.tasks, selector)? else { return Ok(false); }; execute_ai_task(root, &ai_task.id, ai_task, args, policy)?; Ok(true) } fn execute_ai_task( root: &Path, selector: &str, task: &ai_tasks::DiscoveredAiTask, args: &[String], policy: &AiTaskExecutionPolicy, ) -> Result<()> { if policy.use_daemon { return run_via_daemon_with_lazy_start(root, selector, args, policy.no_cache); } if policy.no_cache { ai_tasks::run_task_via_moon(task, root, args) } else { // Auto runtime policy currently resolves to cache-first with safe moon-run fallback. ai_tasks::run_task(task, root, args) } } fn run_via_daemon_with_lazy_start( root: &Path, selector: &str, args: &[String], no_cache: bool, ) -> Result<()> { match ai_taskd::run_via_daemon(root, selector, args, no_cache) { Ok(()) => Ok(()), Err(first_error) => { let msg = format!("{first_error:#}").to_ascii_lowercase(); let daemon_unavailable = msg.contains("failed to connect to ai-taskd") || msg.contains("connection refused") || msg.contains("no such file or directory"); if !daemon_unavailable { return Err(first_error); } ai_taskd::start()?; ai_taskd::run_via_daemon(root, selector, args, no_cache) } } } /// Fuzzy search through task history (most recent first). fn fuzzy_search_task_history() -> Result<()> { let records = history::load_unique_task_records()?; if records.is_empty() { println!("No task history found."); return Ok(()); } // Format for fzf: "task_name project_path" let lines: Vec<String> = records .iter() .map(|r| { let project = r .project_root .strip_prefix( &dirs::home_dir() .unwrap_or_default() .to_string_lossy() .to_string(), ) .map(|p| format!("~{}", p)) .unwrap_or_else(|| r.project_root.clone()); format!("{}\t{}", r.task_name, project) }) .collect(); let input = lines.join("\n"); // Run fzf let mut fzf = Command::new("fzf") .args([ "--height=50%", "--reverse", "--prompt=Task: ", "--delimiter=\t", "--with-nth=1,2", "--tabstop=4", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .context("failed to spawn fzf")?; fzf.stdin.as_mut().unwrap().write_all(input.as_bytes())?; let output = fzf.wait_with_output()?; if !output.status.success() { return Ok(()); // User cancelled } let selected = String::from_utf8_lossy(&output.stdout).trim().to_string(); if selected.is_empty() { return Ok(()); } // Parse selection: "task_name\tproject_path" let parts: Vec<&str> = selected.split('\t').collect(); if parts.is_empty() { return Ok(()); } let task_name = parts[0].trim(); let project_path = if parts.len() > 1 { let p = parts[1].trim(); if p.starts_with("~/") { dirs::home_dir() .unwrap_or_default() .join(&p[2..]) .to_string_lossy() .to_string() } else { p.to_string() } } else { std::env::current_dir()?.to_string_lossy().to_string() }; // Run the task in that project println!("Running '{}' in {}", task_name, project_path); let project_root = PathBuf::from(&project_path); let config_path = project_root.join("flow.toml"); run(TaskRunOpts { config: config_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: task_name.to_string(), args: vec![], }) } /// List tasks from flow.toml (moved from `f tasks` to `f tasks list`). fn list_tasks(opts: TasksListOpts) -> Result<()> { let snapshot = ProjectSnapshot::from_task_config(&opts.config, true)?; if opts.dupes { return print_duplicate_tasks(&snapshot.discovery.tasks); } if !snapshot.has_any_tasks() { println!( "No tasks defined in {} or subdirectories", snapshot.root.display() ); return Ok(()); } println!("Tasks (root: {}):", snapshot.root.display()); for line in format_discovered_task_lines(&snapshot.discovery.tasks, &snapshot.ai_tasks) { println!("{line}"); } Ok(()) } fn list_task_duplicates(opts: TasksDupesOpts) -> Result<()> { let snapshot = ProjectSnapshot::from_task_config_tasks_only(&opts.config, true)?; print_duplicate_tasks(&snapshot.discovery.tasks) } fn init_ai_tasks(opts: TasksInitAiOpts) -> Result<()> { let root = if opts.root.is_absolute() { opts.root } else { std::env::current_dir()?.join(opts.root) }; let task_dir = root.join(".ai").join("tasks"); std::fs::create_dir_all(&task_dir) .with_context(|| format!("failed to create {}", task_dir.display()))?; let starter_path = task_dir.join("starter.mbt"); if starter_path.exists() && !opts.force { println!("AI task starter already exists: {}", starter_path.display()); println!("Use --force to overwrite."); return Ok(()); } std::fs::write(&starter_path, AI_TASK_STARTER) .with_context(|| format!("failed to write {}", starter_path.display()))?; println!("Created AI task starter: {}", starter_path.display()); println!("Run it with: f starter"); Ok(()) } pub fn list(opts: TasksOpts) -> Result<()> { let snapshot = ProjectSnapshot::from_task_config(&opts.config, true)?; if !snapshot.has_any_tasks() { println!( "No tasks defined in {} or subdirectories", snapshot.root.display() ); return Ok(()); } println!("Tasks (root: {}):", snapshot.root.display()); for line in format_discovered_task_lines(&snapshot.discovery.tasks, &snapshot.ai_tasks) { println!("{line}"); } Ok(()) } /// Run tasks from the global flow config (~/.config/flow/flow.toml). pub fn run_global(opts: GlobalCommand) -> Result<()> { let config_path = config::default_config_path(); if !config_path.exists() { bail!("global flow config not found at {}", config_path.display()); } if let Some(action) = opts.action { match action { GlobalAction::List => { return list(TasksOpts { config: config_path, }); } GlobalAction::Run { task, args } => { return run(TaskRunOpts { config: config_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: task, args, }); } GlobalAction::Match(opts) => { return task_match::run_global(task_match::MatchOpts { args: opts.query, model: opts.model, port: Some(opts.port), execute: !opts.dry_run, }); } } } if opts.list { return list(TasksOpts { config: config_path, }); } if let Some(task) = opts.task { return run(TaskRunOpts { config: config_path, delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: task, args: opts.args, }); } list(TasksOpts { config: config_path, }) } /// Run a task, searching nested flow.toml files if not found in root. pub fn run_with_discovery(task_name: &str, args: Vec<String>) -> Result<()> { let snapshot = ProjectSnapshot::from_current_dir(true)?; if !snapshot.has_any_tasks() { bail!( "No tasks defined in {} or subdirectories", snapshot.root.display() ); } let discovered = select_discovered_task(&snapshot.discovery, task_name)?; if let Some(discovered) = discovered { return run(TaskRunOpts { config: discovered.config_path.clone(), delegate_to_hub: false, hub_host: std::net::IpAddr::from([127, 0, 0, 1]), hub_port: 9050, name: discovered.task.name.clone(), args, }); } let ai_policy = AiTaskExecutionPolicy::from_env(); if execute_ai_task_by_selector(&snapshot.root, task_name, &args, &ai_policy)? { return Ok(()); } // List available tasks in error message let available: Vec<_> = snapshot .discovery .tasks .iter() .map(task_reference) .collect(); let mut available_all = available; available_all.extend(snapshot.ai_tasks.iter().map(ai_tasks::task_reference)); bail!( "task '{}' not found.\nAvailable tasks: {}", task_name, available_all.join(", ") ); } fn select_discovered_task<'a>( discovery: &'a discover::DiscoveryResult, task_name: &str, ) -> Result<Option<&'a discover::DiscoveredTask>> { let mut scoped_not_found: Option<(String, String, Vec<String>)> = None; if let Some((scope, scoped_task)) = parse_scoped_selector(task_name) { let scope_exists = discovery.tasks.iter().any(|d| d.matches_scope(&scope)); if scope_exists { let scoped_matches: Vec<&discover::DiscoveredTask> = discovery .tasks .iter() .filter(|d| d.matches_scope(&scope)) .filter(|d| task_matches_selector(d, &scoped_task)) .collect(); let selected = if scoped_matches.is_empty() { let needle = scoped_task.to_ascii_lowercase(); if needle.len() < 2 { None } else { let mut matches = discovery.tasks.iter().filter(|d| { d.matches_scope(&scope) && generate_abbreviation(&d.task.name) .map(|abbr| abbr == needle) .unwrap_or(false) }); let first = matches.next(); if first.is_some() && matches.next().is_none() { first } else { None } } } else if scoped_matches.len() == 1 { Some(scoped_matches[0]) } else { return Err(ambiguous_task_error(task_name, &scoped_matches)); }; if let Some(discovered) = selected { return Ok(Some(discovered)); } let scoped_available: Vec<String> = discovery .tasks .iter() .filter(|d| d.matches_scope(&scope)) .map(task_reference) .collect(); scoped_not_found = Some((scope, scoped_task, scoped_available)); } } let exact_matches: Vec<&discover::DiscoveredTask> = discovery .tasks .iter() .filter(|d| task_matches_selector(d, task_name)) .collect(); let discovered = if exact_matches.is_empty() { let needle = task_name.to_ascii_lowercase(); if needle.len() < 2 { None } else { let mut matches = discovery.tasks.iter().filter(|d| { generate_abbreviation(&d.task.name) .map(|abbr| abbr == needle) .unwrap_or(false) }); if let Some(first) = matches.next() { if matches.next().is_some() { None } else { Some(first) } } else { None } } } else if exact_matches.len() == 1 { Some(exact_matches[0]) } else { Some(resolve_ambiguous_task_match( task_name, &exact_matches, discovery.root_task_resolution.as_ref(), )?) }; if let Some(discovered) = discovered { return Ok(Some(discovered)); } if let Some((scope, scoped_task, scoped_available)) = scoped_not_found { bail!( "task '{}' not found in scope '{}'.\nAvailable in scope: {}", scoped_task, scope, if scoped_available.is_empty() { "(none)".to_string() } else { scoped_available.join(", ") } ); } Ok(None) } fn parse_scoped_selector(selector: &str) -> Option<(String, String)> { let trimmed = selector.trim(); if let Some((scope, task)) = trimmed.split_once(':') { let scope = scope.trim(); let task = task.trim(); if !scope.is_empty() && !task.is_empty() { return Some((scope.to_string(), task.to_string())); } } if let Some((scope, task)) = trimmed.split_once('/') { let scope = scope.trim(); let task = task.trim(); if !scope.is_empty() && !task.is_empty() { return Some((scope.to_string(), task.to_string())); } } None } fn task_matches_selector(task: &discover::DiscoveredTask, needle: &str) -> bool { task.task.name.eq_ignore_ascii_case(needle) || task .task .shortcuts .iter() .any(|s| s.eq_ignore_ascii_case(needle)) } fn task_reference(task: &discover::DiscoveredTask) -> String { let mut out = format!("{}:{}", task.scope, task.task.name); if !task.relative_dir.is_empty() { out.push_str(&format!(" ({})", task.relative_dir)); } out } fn ambiguous_task_error(task_name: &str, matches: &[&discover::DiscoveredTask]) -> anyhow::Error { let mut msg = String::new(); msg.push_str(&format!("task '{}' is ambiguous.\n", task_name)); msg.push_str("Discovered matches:\n"); for task in matches { msg.push_str(&format!(" - {}\n", task_reference(task))); } msg.push_str("Try one of:\n"); for task in matches { msg.push_str(&format!( " f {}:{}\n f run --config {} {}\n", task.scope, task.task.name, task.config_path.display(), task.task.name )); } anyhow::anyhow!(msg.trim_end().to_string()) } fn resolve_ambiguous_task_match<'a>( query: &str, matches: &[&'a discover::DiscoveredTask], task_resolution: Option<&TaskResolutionConfig>, ) -> Result<&'a discover::DiscoveredTask> { let Some(policy) = task_resolution else { return Err(ambiguous_task_error(query, matches)); }; let mut route_scope: Option<&str> = None; for (task, scope) in &policy.routes { if task.eq_ignore_ascii_case(query) || matches .iter() .any(|m| m.task.name.eq_ignore_ascii_case(task)) { route_scope = Some(scope.as_str()); break; } } if let Some(scope) = route_scope { let routed: Vec<&discover::DiscoveredTask> = matches .iter() .copied() .filter(|m| m.matches_scope(scope)) .collect(); if routed.len() == 1 { if policy.warn_on_implicit_scope.unwrap_or(false) { eprintln!( "note: routed '{}' to scope '{}' via [task_resolution.routes].", query, scope ); } return Ok(routed[0]); } if routed.len() > 1 { return Err(ambiguous_task_error(query, &routed)); } } for scope in &policy.preferred_scopes { let preferred: Vec<&discover::DiscoveredTask> = matches .iter() .copied() .filter(|m| m.matches_scope(scope)) .collect(); if preferred.len() == 1 { if policy.warn_on_implicit_scope.unwrap_or(false) { eprintln!( "note: selected '{}' from preferred scope '{}'.", query, scope ); } return Ok(preferred[0]); } if preferred.len() > 1 { return Err(ambiguous_task_error(query, &preferred)); } } Err(ambiguous_task_error(query, matches)) } pub fn run(opts: TaskRunOpts) -> Result<()> { let config_path_for_deps = opts.config.clone(); let (config_path, cfg) = load_project_config(opts.config)?; let project_name = cfg.project_name.clone(); let workdir = config_path.parent().unwrap_or(Path::new(".")); maybe_warn_non_fishx(); // Set active project when running a task if let Some(ref name) = project_name { let _ = projects::set_active_project(name); } let ai_policy = AiTaskExecutionPolicy::from_env(); let task = if let Some(task) = find_task(&cfg, &opts.name) { task } else { if execute_ai_task_by_selector(workdir, &opts.name, &opts.args, &ai_policy)? { return Ok(()); } bail!( "task '{}' not found in {}", opts.name, config_path.display() ); }; // Build user_input early so we can record failures let quoted_args: Vec<String> = opts .args .iter() .map(|arg| shell_words::quote(arg).into_owned()) .collect(); let user_input = if opts.args.is_empty() { task.name.clone() } else { format!("{} {}", task.name, quoted_args.join(" ")) }; let base_command = task.command.trim().to_string(); let display_command = if opts.args.is_empty() { base_command.clone() } else { format!("{} {}", base_command, quoted_args.join(" ")) }; // Helper to record a failed invocation let record_failure = |error_msg: &str| { let mut record = InvocationRecord::new( workdir.display().to_string(), config_path.display().to_string(), project_name.as_deref(), &task.name, &display_command, &user_input, false, ); record.success = false; record.status = Some(1); record.output = error_msg.to_string(); if let Err(err) = history::record(record) { tracing::warn!(?err, "failed to write task history"); } }; // Resolve dependencies and record failure if it fails let resolved = match resolve_task_dependencies(task, &cfg) { Ok(r) => r, Err(err) => { record_failure(&err.to_string()); return Err(err); } }; // Run task dependencies first (tasks that must complete before this one) if !resolved.task_deps.is_empty() { for dep_task_name in &resolved.task_deps { println!("Running dependency task '{}'...", dep_task_name); let dep_opts = TaskRunOpts { config: config_path_for_deps.clone(), delegate_to_hub: false, hub_host: opts.hub_host, hub_port: opts.hub_port, name: dep_task_name.clone(), args: vec![], }; if let Err(err) = run(dep_opts) { record_failure(&format!( "dependency task '{}' failed: {}", dep_task_name, err )); bail!("dependency task '{}' failed: {}", dep_task_name, err); } println!(); } } let should_delegate = opts.delegate_to_hub || task.delegate_to_hub; if should_delegate { match delegate_task_to_hub( task, &resolved, workdir, opts.hub_host, opts.hub_port, &display_command, ) { Ok(()) => { let mut record = InvocationRecord::new( workdir.display().to_string(), config_path.display().to_string(), project_name.as_deref(), &task.name, &display_command, &user_input, false, ); record.success = true; record.status = Some(0); record.output = format!("delegated to hub at {}:{}", opts.hub_host, opts.hub_port); if let Err(err) = history::record(record) { tracing::warn!(?err, "failed to write task history"); } return Ok(()); } Err(err) => { println!( "⚠️ Failed to delegate task '{}' to hub ({}); falling back to local execution.", task.name, err ); } } } let flox_pkgs = collect_flox_packages(&cfg, &resolved.flox); let mut preamble = String::new(); let flox_disabled_env = std::env::var_os("FLOW_DISABLE_FLOX").is_some(); let flox_disabled_marker = flox_disabled_marker(workdir).exists(); let flox_enabled = !flox_pkgs.is_empty() && !flox_disabled_env && !flox_disabled_marker; if flox_enabled { log_and_capture( &mut preamble, &format!( "Skipping host PATH checks; using managed deps [{}]", flox_pkgs .iter() .map(|(name, _)| name.as_str()) .collect::<Vec<_>>() .join(", ") ), ); } else { if flox_disabled_env { log_and_capture( &mut preamble, "FLOW_DISABLE_FLOX is set; running on host PATH", ); } if let Err(err) = ensure_command_dependencies_available(&resolved.commands) { record_failure(&err.to_string()); return Err(err); } } execute_task( task, &config_path, workdir, preamble, project_name.as_deref(), &flox_pkgs, flox_enabled, &base_command, &opts.args, &user_input, ) } pub fn activate(opts: TaskActivateOpts) -> Result<()> { let (config_path, cfg) = load_project_config(opts.config)?; let workdir = config_path.parent().unwrap_or(Path::new(".")); let project_name = cfg.project_name.clone(); let tasks: Vec<&TaskConfig> = cfg .tasks .iter() .filter(|task| task.activate_on_cd_to_root) .collect(); if tasks.is_empty() { return Ok(()); } let mut combined = ResolvedDependencies::default(); for task in &tasks { let resolved = resolve_task_dependencies(task, &cfg)?; combined.commands.extend(resolved.commands); combined.flox.extend(resolved.flox); } let flox_pkgs = collect_flox_packages(&cfg, &combined.flox); let mut preamble = String::new(); if flox_pkgs.is_empty() { ensure_command_dependencies_available(&combined.commands)?; } else { log_and_capture( &mut preamble, &format!( "Skipping host PATH checks; using managed deps [{}]", flox_pkgs .iter() .map(|(name, _)| name.as_str()) .collect::<Vec<_>>() .join(", ") ), ); } for task in tasks { let flox_disabled_env = std::env::var_os("FLOW_DISABLE_FLOX").is_some(); let flox_disabled_marker = flox_disabled_marker(workdir).exists(); let flox_enabled = !flox_pkgs.is_empty() && !flox_disabled_env && !flox_disabled_marker; let command = task.command.trim().to_string(); let empty_args: Vec<String> = Vec::new(); execute_task( task, &config_path, workdir, preamble.clone(), project_name.as_deref(), &flox_pkgs, flox_enabled, &command, &empty_args, &task.name, )?; } Ok(()) } pub(crate) fn load_project_config(path: PathBuf) -> Result<(PathBuf, Config)> { let mut config_path = resolve_path(path)?; if !config_path.exists() { let is_default = project_snapshot::is_default_flow_config(&config_path); if is_default { if let Some(found) = project_snapshot::find_flow_toml_upwards( config_path.parent().unwrap_or_else(|| Path::new(".")), ) { config_path = found; } else { init::write_template(&config_path)?; println!("Created starter flow.toml at {}", config_path.display()); } } } let cfg = config::load(&config_path).with_context(|| { format!( "failed to load flow tasks configuration at {}", config_path.display() ) })?; if let Some(name) = cfg.project_name.as_deref() { if let Err(err) = projects::register_project(name, &config_path) { tracing::debug!(?err, "failed to register project name"); } } Ok((config_path, cfg)) } fn resolve_path(path: PathBuf) -> Result<PathBuf> { if path.is_absolute() { Ok(path) } else { Ok(std::env::current_dir()?.join(path)) } } fn log_and_capture(buf: &mut String, msg: &str) { println!("{msg}"); buf.push_str(msg); if !msg.ends_with('\n') { buf.push('\n'); } } fn log_dir() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".config/flow/logs") } fn sanitize_component(raw: &str) -> String { let mut s = String::with_capacity(raw.len()); for ch in raw.chars() { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { s.push(ch); } else { s.push('-'); } } s.trim_matches('-').to_lowercase() } fn short_hash(input: &str) -> String { let mut hasher = DefaultHasher::new(); input.hash(&mut hasher); format!("{:x}", hasher.finish()) } fn task_log_path(ctx: &TaskContext) -> Option<PathBuf> { let base = log_dir(); let project_root_key = ctx.project_root.display().to_string(); let project_root_hash = short_hash(&project_root_key); let slug = if let Some(name) = ctx.project_name.as_deref() { let clean = sanitize_component(name); if clean.is_empty() { format!("proj-{project_root_hash}") } else { format!("{clean}-{project_root_hash}") } } else { format!("proj-{project_root_hash}") }; let task = { let clean = sanitize_component(&ctx.task_name); if clean.is_empty() { "task".to_string() } else { clean } }; Some(base.join(slug).join(format!("{task}.log"))) } fn task_output_path(raw: &str, workdir: &Path) -> PathBuf { let expanded = config::expand_path(raw); if expanded.is_absolute() { expanded } else { workdir.join(expanded) } } fn execute_task( task: &TaskConfig, config_path: &Path, workdir: &Path, mut preamble: String, project_name: Option<&str>, flox_pkgs: &[(String, FloxInstallSpec)], flox_enabled: bool, command: &str, args: &[String], user_input: &str, ) -> Result<()> { if command.is_empty() { bail!("task '{}' has an empty command", task.name); } log_and_capture( &mut preamble, &format!("Running task '{}': {}", task.name, command), ); // Create context for PID tracking let canonical_config = config_path .canonicalize() .unwrap_or_else(|_| config_path.to_path_buf()); let canonical_workdir = workdir .canonicalize() .unwrap_or_else(|_| workdir.to_path_buf()); // Auto-detect interactive mode if not explicitly set let interactive = task.interactive || needs_interactive_mode(command); let task_ctx = TaskContext { task_name: task.name.clone(), command: command.to_string(), config_path: canonical_config, project_root: canonical_workdir.clone(), used_flox: flox_enabled && !flox_pkgs.is_empty(), project_name: project_name.map(|s| s.to_string()), log_path: None, interactive, }; // Set up cancel handler if on_cancel is defined setup_cancel_handler(task.on_cancel.as_deref(), workdir); let mut record = InvocationRecord::new( workdir.display().to_string(), config_path.display().to_string(), project_name, &task.name, command, user_input, !flox_pkgs.is_empty(), ); let started = Instant::now(); let mut combined_output = preamble; let status: ExitStatus; let flox_disabled = flox_disabled_marker(workdir).exists(); if flox_pkgs.is_empty() || flox_disabled || !flox_enabled { let (st, out) = run_host_command(workdir, command, args, Some(task_ctx.clone()))?; status = st; combined_output.push_str(&out); } else { log_and_capture( &mut combined_output, &format!( "Skipping host PATH checks; using managed deps [{}]", flox_pkgs .iter() .map(|(name, _)| name.as_str()) .collect::<Vec<_>>() .join(", ") ), ); match flox_health_check(workdir, flox_pkgs) { Ok(true) => { match run_flox_with_reset(flox_pkgs, workdir, command, args, Some(task_ctx.clone())) { Ok(Some((st, out))) => { combined_output.push_str(&out); if st.success() { status = st; } else { log_and_capture( &mut combined_output, &format!( "flox activate failed (status {:?}); retrying on host PATH", st.code() ), ); let (host_status, host_out) = run_host_command(workdir, command, args, Some(task_ctx.clone()))?; combined_output .push_str("\n[flox activate failed; retried on host PATH]\n"); combined_output.push_str(&host_out); status = host_status; } } Ok(None) => { log_and_capture( &mut combined_output, "flox disabled after repeated errors; using host PATH", ); combined_output.push_str("[flox disabled after errors]\n"); let (host_status, host_out) = run_host_command(workdir, command, args, Some(task_ctx.clone()))?; combined_output.push_str(&host_out); status = host_status; } Err(err) => { log_and_capture( &mut combined_output, &format!("flox activate failed ({err}); retrying on host PATH"), ); let (host_status, host_out) = run_host_command(workdir, command, args, Some(task_ctx.clone()))?; combined_output .push_str("\n[flox activate failed; retried on host PATH]\n"); combined_output.push_str(&host_out); status = host_status; } } } Ok(false) => { log_and_capture( &mut combined_output, "flox disabled after health check; using host PATH", ); combined_output.push_str("[flox disabled after health check]\n"); let (host_status, host_out) = run_host_command(workdir, command, args, Some(task_ctx.clone()))?; combined_output.push_str(&host_out); status = host_status; } Err(err) => { log_and_capture( &mut combined_output, &format!("flox health check failed ({err}); using host PATH"), ); combined_output.push_str("[flox health check failed; using host PATH]\n"); let (host_status, host_out) = run_host_command(workdir, command, args, Some(task_ctx))?; combined_output.push_str(&host_out); status = host_status; } } } record.duration_ms = started.elapsed().as_millis(); record.status = status.code(); record.success = status.success(); record.output = combined_output; let output = record.output.clone(); if let Some(output_file) = task.output_file.as_deref() { let path = task_output_path(output_file, workdir); if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } if let Err(err) = fs::write(&path, record.output.as_bytes()) { tracing::warn!(?err, path = %path.display(), "failed to write task output file"); } } // Record to jazz2 first (borrows), then history (takes ownership) if let Err(err) = jazz_state::record_task_run(&record) { tracing::warn!(?err, "failed to write jazz2 task run"); } if let Err(err) = history::record(record) { tracing::warn!(?err, "failed to write task history"); } // Clear cancel handler since task completed normally clear_cancel_handler(); if status.success() { Ok(()) } else { write_failure_bundle( &task.name, command, workdir, config_path, project_name, &output, status.code(), ); task_failure_agents::maybe_run_task_failure_agents( &task.name, command, workdir, &output, status.code(), ); maybe_run_task_failure_hook(&task.name, command, workdir, &output, status.code()); bail!( "task '{}' exited with status {}", task.name, status.code().unwrap_or(-1) ); } } #[cfg(test)] fn format_task_lines(tasks: &[TaskConfig]) -> Vec<String> { let mut lines = Vec::new(); for (idx, task) in tasks.iter().enumerate() { let shortcut_display = if task.shortcuts.is_empty() { String::new() } else { format!(" [{}]", task.shortcuts.join(", ")) }; lines.push(format!( "{:>2}. {}{} – {}", idx + 1, task.name, shortcut_display, task.command )); if let Some(desc) = &task.description { lines.push(format!(" {desc}")); } } lines } fn format_discovered_task_lines( tasks: &[discover::DiscoveredTask], ai_tasks_list: &[ai_tasks::DiscoveredAiTask], ) -> Vec<String> { let mut lines = Vec::new(); for (idx, discovered) in tasks.iter().enumerate() { let task = &discovered.task; let shortcut_display = if task.shortcuts.is_empty() { String::new() } else { format!(" [{}]", task.shortcuts.join(", ")) }; // Keep relative path visible for debugging where each selector resolves. let path_suffix = if let Some(path_label) = discovered.path_label() { format!(" ({})", path_label) } else { String::new() }; lines.push(format!( "{:>2}. {}:{}{}{} – {}", idx + 1, discovered.scope, task.name, shortcut_display, path_suffix, task.command )); if let Some(desc) = &task.description { lines.push(format!(" {desc}")); } } let base = lines.len(); for (idx, task) in ai_tasks_list.iter().enumerate() { let tags = if task.tags.is_empty() { String::new() } else { format!(" [{}]", task.tags.join(",")) }; lines.push(format!( "{:>2}. {}{} ({}) – moon run {}", base + idx + 1, task.id, tags, task.relative_path, task.path.display() )); if !task.description.trim().is_empty() { lines.push(format!(" {}", task.description.trim())); } } lines } fn print_duplicate_tasks(tasks: &[discover::DiscoveredTask]) -> Result<()> { let mut by_name: BTreeMap<String, Vec<&discover::DiscoveredTask>> = BTreeMap::new(); for task in tasks { by_name .entry(task.task.name.to_ascii_lowercase()) .or_default() .push(task); } let mut duplicates: Vec<(String, Vec<&discover::DiscoveredTask>)> = by_name .into_iter() .filter_map(|(name, mut entries)| { if entries.len() < 2 { return None; } entries.sort_by(|a, b| { a.scope .cmp(&b.scope) .then_with(|| a.relative_dir.cmp(&b.relative_dir)) }); Some((name, entries)) }) .collect(); duplicates.sort_by(|a, b| a.0.cmp(&b.0)); if duplicates.is_empty() { println!("No duplicate task names found."); return Ok(()); } println!("Duplicate task names:"); for (name, entries) in duplicates { println!(); println!(" {} ({})", name, entries.len()); for entry in entries { println!( " - {}:{} [{}]", entry.scope, entry.task.name, entry.config_path.display() ); } } Ok(()) } const AI_TASK_STARTER: &str = r#"// title: Starter AI Task // description: Example MoonBit task under .ai/tasks. // tags: [ai, moonbit, task] // // Run with: // f starter // or: // f ai:starter fn main { println("starter ai task: ok") } "#; pub(crate) fn find_task<'a>(cfg: &'a Config, needle: &str) -> Option<&'a TaskConfig> { let normalized = needle.trim(); if normalized.is_empty() { return None; } let index = lookup_index_for(cfg); let normalized = normalized.to_ascii_lowercase(); if let Some(idx) = index.by_name.get(&normalized).copied() { return cfg.tasks.get(idx); } if let Some(idx) = index.by_shortcut.get(&normalized).copied() { return cfg.tasks.get(idx); } if normalized.len() < 2 { return None; } let maybe_idx = index.by_abbreviation.get(&normalized).copied().flatten()?; cfg.tasks.get(maybe_idx) } fn generate_abbreviation(name: &str) -> Option<String> { let mut abbr = String::new(); let mut new_segment = true; for ch in name.chars() { if ch.is_ascii_alphanumeric() { if new_segment { abbr.push(ch.to_ascii_lowercase()); new_segment = false; } } else { new_segment = true; } } if abbr.len() >= 2 { Some(abbr) } else { None } } #[derive(Clone, Debug, Default)] struct TaskLookupIndex { task_count: usize, first_name: String, last_name: String, by_name: HashMap<String, usize>, by_shortcut: HashMap<String, usize>, by_abbreviation: HashMap<String, Option<usize>>, } impl TaskLookupIndex { fn build(tasks: &[TaskConfig]) -> Self { let mut by_name = HashMap::with_capacity(tasks.len()); let mut by_shortcut = HashMap::new(); let mut by_abbreviation = HashMap::new(); for (idx, task) in tasks.iter().enumerate() { by_name.entry(task.name.to_ascii_lowercase()).or_insert(idx); for alias in &task.shortcuts { let normalized = alias.trim().to_ascii_lowercase(); if !normalized.is_empty() { by_shortcut.entry(normalized).or_insert(idx); } } if let Some(abbr) = generate_abbreviation(&task.name) { match by_abbreviation.entry(abbr) { std::collections::hash_map::Entry::Vacant(entry) => { entry.insert(Some(idx)); } std::collections::hash_map::Entry::Occupied(mut entry) => { entry.insert(None); } } } } Self { task_count: tasks.len(), first_name: tasks.first().map(|t| t.name.clone()).unwrap_or_default(), last_name: tasks.last().map(|t| t.name.clone()).unwrap_or_default(), by_name, by_shortcut, by_abbreviation, } } fn looks_like(&self, tasks: &[TaskConfig]) -> bool { if self.task_count != tasks.len() { return false; } let first = tasks.first().map(|t| t.name.as_str()).unwrap_or_default(); let last = tasks.last().map(|t| t.name.as_str()).unwrap_or_default(); self.first_name == first && self.last_name == last } } fn task_lookup_cache() -> &'static Mutex<HashMap<usize, TaskLookupIndex>> { static CACHE: std::sync::OnceLock<Mutex<HashMap<usize, TaskLookupIndex>>> = std::sync::OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } fn lookup_index_for(cfg: &Config) -> TaskLookupIndex { let cache_key = cfg as *const Config as usize; let tasks = &cfg.tasks; let Ok(mut cache) = task_lookup_cache().lock() else { return TaskLookupIndex::build(tasks); }; if let Some(existing) = cache.get(&cache_key) && existing.looks_like(tasks) { return existing.clone(); } let index = TaskLookupIndex::build(tasks); cache.insert(cache_key, index.clone()); index } /// Check if command already references shell positional args ($@, $*, $1, etc.) fn command_references_args(command: &str) -> bool { // Look for $@, $*, $1-$9, ${1}, ${@}, etc. let mut chars = command.chars().peekable(); while let Some(c) = chars.next() { if c == '$' { match chars.peek() { Some('@') | Some('*') | Some('1'..='9') => return true, Some('{') => { // Check for ${1}, ${@}, ${*}, etc. chars.next(); match chars.peek() { Some('@') | Some('*') | Some('1'..='9') => return true, _ => {} } } _ => {} } } } false } fn has_tty_access() -> bool { if std::io::stdin().is_terminal() { return true; } #[cfg(unix)] { std::fs::File::open("/dev/tty").is_ok() } #[cfg(not(unix))] { false } } fn fishx_enabled() -> bool { match env::var("FISHX") { Ok(value) => { let value = value.trim().to_lowercase(); value == "1" || value == "true" || value == "yes" || value == "on" } Err(_) => false, } } fn maybe_warn_non_fishx() { if !std::io::stdin().is_terminal() { return; } if fishx_enabled() { return; } if env::var_os("FLOW_ALLOW_NON_FISHX").is_some() { return; } if FISHX_WARNED.swap(true, Ordering::Relaxed) { return; } // Only warn if fishx is installed but not active — contributors who // never installed fishx shouldn't see a confusing warning. if which::which("fishx").is_err() { return; } eprintln!( "⚠️ fishx is installed but not active. Flow runs best under fishx for error capture and AI hints.\n\ Tip: run `f deploy-login` in the fishx repo or set FLOW_ALLOW_NON_FISHX=1 to hide this warning." ); } fn failure_bundle_path() -> Option<PathBuf> { if let Ok(path) = env::var("FISHX_FAILURE_PATH") { let trimmed = path.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } if let Ok(path) = env::var("FLOW_FAILURE_BUNDLE_PATH") { let trimmed = path.trim(); if !trimmed.is_empty() { return Some(PathBuf::from(trimmed)); } } dirs::cache_dir().map(|dir| dir.join("flow").join("last-task-failure.json")) } fn resolve_task_failure_hook() -> Option<String> { if let Ok(value) = env::var("FLOW_TASK_FAILURE_HOOK") { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } let config = config::load_ts_config()?; let flow = config.flow?; let hook = flow.task_failure_hook?; let trimmed = hook.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn truncate_output_for_hook(output: &str, max_lines: usize, max_chars: usize) -> String { let mut lines: Vec<&str> = output.lines().collect(); if lines.len() > max_lines { lines = lines[lines.len().saturating_sub(max_lines)..].to_vec(); } let mut joined = lines.join("\n"); if joined.len() > max_chars { let start = joined.len().saturating_sub(max_chars); joined = format!("...{}", &joined[start..]); } joined } fn maybe_run_task_failure_hook( task_name: &str, command: &str, workdir: &Path, output: &str, status: Option<i32>, ) { if env::var_os("FLOW_DISABLE_TASK_FAILURE_HOOK").is_some() { return; } let Some(hook) = resolve_task_failure_hook() else { return; }; if !std::io::stdin().is_terminal() { return; } let mut hook = hook; if env::var_os("FLOW_TASK_FAILURE_HOOK_ALLOW_OPEN").is_none() { let hook_lower = hook.to_ascii_lowercase(); if hook_lower.contains("rise work") { hook = sanitize_rise_work_hook_no_open(&hook); } } let mut cmd = Command::new("sh"); cmd.arg("-c") .arg(&hook) .current_dir(workdir) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); cmd.env("FLOW_TASK_NAME", task_name); cmd.env("FLOW_TASK_COMMAND", secret_redact::redact_text(command)); cmd.env("FLOW_TASK_WORKDIR", workdir.display().to_string()); cmd.env("FLOW_TASK_STATUS", status.unwrap_or(-1).to_string()); if let Some(path) = failure_bundle_path() { cmd.env("FLOW_FAILURE_BUNDLE_PATH", path.display().to_string()); } let tail = truncate_output_for_hook(output, 120, 12000); if !tail.is_empty() { cmd.env("FLOW_TASK_OUTPUT_TAIL", secret_redact::redact_text(&tail)); } match cmd.status() { Ok(status) if status.success() => {} Ok(status) => { eprintln!("⚠ task failure hook exited with status {:?}", status.code()); } Err(err) => { eprintln!("⚠ failed to run task failure hook: {}", err); } } } fn sanitize_rise_work_hook_no_open(hook: &str) -> String { let tokens = match shell_words::split(hook) { Ok(tokens) => tokens, Err(_) => { let mut fallback = hook.to_string(); let lower = fallback.to_ascii_lowercase(); if !lower.contains("--no-open") { fallback.push_str(" --no-open"); } return fallback; } }; let mut cleaned: Vec<String> = Vec::new(); let mut skip_next = false; for token in tokens { if skip_next { skip_next = false; continue; } let lower = token.to_ascii_lowercase(); if lower == "--focus" { continue; } if lower == "--focus-app" || lower == "--app" || lower == "--target" { skip_next = true; continue; } if lower.starts_with("--focus-app=") || lower.starts_with("--app=") || lower.starts_with("--target=") { continue; } cleaned.push(token); } let mut rebuilt = shell_words::join(cleaned); let lower = rebuilt.to_ascii_lowercase(); if !lower.contains("--no-open") { if !rebuilt.is_empty() { rebuilt.push(' '); } rebuilt.push_str("--no-open"); } rebuilt } fn truncate_for_bundle(output: &str, max_chars: usize) -> String { if output.len() <= max_chars { return output.to_string(); } let start = output.len().saturating_sub(max_chars); format!("...{}", &output[start..]) } fn write_failure_bundle( task_name: &str, command: &str, workdir: &Path, config_path: &Path, project_name: Option<&str>, output: &str, status: Option<i32>, ) { let Some(path) = failure_bundle_path() else { return; }; let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let payload = json!({ "task": task_name, "command": secret_redact::redact_text(command), "workdir": workdir.display().to_string(), "config": config_path.display().to_string(), "project": project_name, "status": status.unwrap_or(-1), "output": secret_redact::redact_text(&truncate_for_bundle(output, 20_000)), "fishx": fishx_enabled(), "ts": ts, }); if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } if let Err(err) = fs::write(&path, payload.to_string().as_bytes()) { tracing::warn!(?err, path = %path.display(), "failed to write task failure bundle"); return; } if std::io::stdin().is_terminal() { eprintln!("🧩 failure bundle: {}", path.display()); if which("fx-failure").is_ok() { eprintln!(" Tip: run `fx-failure` or `last-error` for a quick fix prompt."); } } } fn run_host_command( workdir: &Path, command: &str, args: &[String], ctx: Option<TaskContext>, ) -> Result<(ExitStatus, String)> { // For interactive tasks, run directly with inherited stdio // This ensures proper TTY handling for readline, prompts, etc. let interactive = ctx.as_ref().map(|c| c.interactive).unwrap_or(false); let is_tty = has_tty_access(); if interactive && is_tty { return run_command_with_pty(workdir, command, args, ctx); } let mut cmd = Command::new("/bin/sh"); // If args are provided and command doesn't already reference them ($@ or $1, $2, etc.), // append "$@" to pass them through properly let full_command = if args.is_empty() || command_references_args(command) { command.to_string() } else { format!("{} \"$@\"", command) }; cmd.arg("-c").arg(&full_command); if !args.is_empty() { cmd.arg("sh"); // $0 placeholder for arg in args { cmd.arg(arg); } } cmd.current_dir(workdir); inject_global_env(&mut cmd); run_command_with_tee(cmd, ctx).with_context(|| "failed to spawn command without managed env") } fn run_flox_with_reset( flox_pkgs: &[(String, FloxInstallSpec)], workdir: &Path, command: &str, args: &[String], ctx: Option<TaskContext>, ) -> Result<Option<(ExitStatus, String)>> { let mut combined_output = String::new(); let mut reset_done = false; loop { let env = flox::ensure_env(workdir, flox_pkgs)?; match run_flox_command(&env, workdir, command, args, ctx.clone()) { Ok((status, out)) => { combined_output.push_str(&out); if status.success() { return Ok(Some((status, combined_output))); } if !reset_done { reset_flox_env(workdir)?; combined_output .push_str("\n[flox activate failed; reset .flox and retrying]\n"); reset_done = true; continue; } mark_flox_disabled(workdir, "flox activate repeatedly failed")?; return Ok(None); } Err(err) => { combined_output.push_str(&format!("[flox activate error: {err}]\n")); if !reset_done { reset_flox_env(workdir)?; combined_output.push_str("[reset .flox and retrying]\n"); reset_done = true; continue; } mark_flox_disabled(workdir, "flox activate error after reset")?; return Ok(None); } } } } fn flox_health_check(project_root: &Path, flox_pkgs: &[(String, FloxInstallSpec)]) -> Result<bool> { let env = flox::ensure_env(project_root, flox_pkgs)?; let flox_bin = which("flox").context("flox is required to run tasks with flox deps")?; let mut cmd = Command::new(flox_bin); cmd.arg("activate") .arg("-d") .arg(&env.project_root) .arg("--") .arg("/bin/sh") .arg("-c") .arg(":") .current_dir(project_root) .stdout(Stdio::null()) .stderr(Stdio::null()); match cmd.status() { Ok(status) if status.success() => Ok(true), _ => { mark_flox_disabled(project_root, "flox health check failed")?; Ok(false) } } } fn run_flox_command( env: &FloxEnv, workdir: &Path, command: &str, args: &[String], ctx: Option<TaskContext>, ) -> Result<(ExitStatus, String)> { // For interactive tasks, run directly with inherited stdio let interactive = ctx.as_ref().map(|c| c.interactive).unwrap_or(false); if interactive && has_tty_access() { // Build a single command string that wraps the user command inside // `flox activate`, then hand it to the PTY path for full interactivity // + output capture. let flox_bin = which("flox").context("flox is required to run tasks with flox deps")?; let inner = if args.is_empty() || command_references_args(command) { command.to_string() } else { format!("{} \"$@\"", command) }; let flox_cmd = format!( "{} activate -d {} -- /bin/sh -c {}", shell_words::quote(&flox_bin.to_string_lossy()), shell_words::quote(&env.project_root.to_string_lossy()), shell_words::quote(&inner), ); return run_command_with_pty(workdir, &flox_cmd, args, ctx); } let flox_bin = which("flox").context("flox is required to run tasks with flox deps")?; // If args are provided and command doesn't already reference them, // append "$@" to pass them through properly let full_command = if args.is_empty() || command_references_args(command) { command.to_string() } else { format!("{} \"$@\"", command) }; let mut cmd = Command::new(flox_bin); cmd.arg("activate") .arg("-d") .arg(&env.project_root) .arg("--") .arg("/bin/sh") .arg("-c") .arg(&full_command); if !args.is_empty() { cmd.arg("sh"); // $0 placeholder for arg in args { cmd.arg(arg); } } cmd.current_dir(workdir); inject_global_env(&mut cmd); run_command_with_tee(cmd, ctx).with_context(|| "failed to spawn flox activate for task") } fn run_command_with_tee( mut cmd: Command, ctx: Option<TaskContext>, ) -> Result<(ExitStatus, String)> { inject_global_env(&mut cmd); // Interactive commands are now caught upstream by run_host_command / // run_flox_command and routed through run_command_with_pty, so this // always delegates to the pipe-based path. run_command_with_pipes(cmd, ctx) } fn inject_global_env(cmd: &mut Command) { let keys = config::global_env_keys(); if keys.is_empty() { return; } let missing: Vec<String> = keys .into_iter() .filter(|key| std::env::var_os(key).is_none()) .collect(); if missing.is_empty() { return; } // If not logged in to cloud, silently try local env store and skip. // This avoids prompting "Not logged in to cloud..." for contributors // who don't need cloud env vars (e.g. web-only dev). if !crate::env::has_cloud_auth_token() { match crate::env::fetch_local_personal_env_vars(&missing) { Ok(vars) => { for (key, value) in vars { if !value.is_empty() { cmd.env(key, value); } } } Err(err) => { tracing::debug!(?err, "failed to read local env vars"); } } return; } match crate::env::fetch_personal_env_vars(&missing) { Ok(vars) => { for (key, value) in vars { if !value.is_empty() { cmd.env(key, value); } } } Err(err) => { tracing::debug!(?err, "failed to fetch global env vars"); } } } /// Inject global env vars into a `portable_pty::CommandBuilder`. fn inject_global_env_pty(cmd: &mut CommandBuilder) { let keys = config::global_env_keys(); if keys.is_empty() { return; } let missing: Vec<String> = keys .into_iter() .filter(|key| std::env::var_os(key).is_none()) .collect(); if missing.is_empty() { return; } if !crate::env::has_cloud_auth_token() { match crate::env::fetch_local_personal_env_vars(&missing) { Ok(vars) => { for (key, value) in vars { if !value.is_empty() { cmd.env(key, value); } } } Err(err) => { tracing::debug!(?err, "failed to read local env vars"); } } return; } match crate::env::fetch_personal_env_vars(&missing) { Ok(vars) => { for (key, value) in vars { if !value.is_empty() { cmd.env(key, value); } } } Err(err) => { tracing::debug!(?err, "failed to fetch global env vars"); } } } /// Run a command inside a PTY with full interactivity, color support, and output /// capture. Enables raw mode so keystrokes pass through unbuffered, installs a /// SIGWINCH handler to propagate terminal resizes, and uses `poll(2)` on stdin /// so the forwarding thread exits promptly when the child terminates. fn run_command_with_pty( workdir: &Path, command: &str, args: &[String], ctx: Option<TaskContext>, ) -> Result<(ExitStatus, String)> { let pty_system = NativePtySystem::default(); // Get terminal size or use defaults let size = crossterm::terminal::size() .map(|(cols, rows)| PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }) .unwrap_or(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0, }); let pair = pty_system .openpty(size) .map_err(|e| anyhow::anyhow!("failed to open pty: {}", e))?; // Build the shell command, appending "$@" for positional args if needed let full_command = if args.is_empty() || command_references_args(command) { command.to_string() } else { format!("{} \"$@\"", command) }; let mut pty_cmd = CommandBuilder::new("/bin/sh"); pty_cmd.arg("-c"); pty_cmd.arg(&full_command); if !args.is_empty() { pty_cmd.arg("sh"); // $0 placeholder for arg in args { pty_cmd.arg(arg); } } pty_cmd.cwd(workdir); // Enable full color support in child processes pty_cmd.env("TERM", "xterm-256color"); pty_cmd.env("COLORTERM", "truecolor"); inject_global_env_pty(&mut pty_cmd); let mut child = pair .slave .spawn_command(pty_cmd) .map_err(|e| anyhow::anyhow!("failed to spawn command in pty: {}", e))?; // Drop the slave side in the parent drop(pair.slave); let pid = child.process_id().unwrap_or(0); set_cleanup_process(pid, pid); // Register the process if we have task context if let Some(ref task_ctx) = ctx { let entry = RunningProcess { pid, pgid: pid, // PTY processes are their own group task_name: task_ctx.task_name.clone(), command: task_ctx.command.clone(), started_at: running::now_ms(), config_path: task_ctx.config_path.clone(), project_root: task_ctx.project_root.clone(), used_flox: task_ctx.used_flox, project_name: task_ctx.project_name.clone(), }; if let Err(err) = running::register_process(entry) { tracing::warn!(?err, "failed to register running process"); } } // Install SIGWINCH handler for terminal resize propagation #[cfg(unix)] unsafe { libc::signal( libc::SIGWINCH, sigwinch_handler as *const () as libc::sighandler_t, ); } // Enable raw mode so every keystroke reaches the child unbuffered crossterm::terminal::enable_raw_mode() .map_err(|e| anyhow::anyhow!("failed to enable raw mode: {}", e))?; RAW_MODE_ACTIVE.store(true, Ordering::SeqCst); let _raw_guard = RawModeGuard; let output = Arc::new(Mutex::new(String::new())); // Set up optional log file let log_file = ctx.as_ref().and_then(|c| { let path = task_log_path(c)?; if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } match OpenOptions::new().create(true).append(true).open(&path) { Ok(mut file) => { let header = format!( "\n--- {} | task:{} | cmd:{} ---\n", running::now_ms(), c.task_name, c.command ); let _ = file.write_all(header.as_bytes()); Some(Arc::new(Mutex::new(file))) } Err(_) => None, } }); // Get reader/writer for PTY master let mut reader = pair .master .try_clone_reader() .map_err(|e| anyhow::anyhow!("failed to clone pty reader: {}", e))?; // Keep the master alive so SIGWINCH can resize it; take_writer for stdin let master = pair.master; let mut pty_writer = master .take_writer() .map_err(|e| anyhow::anyhow!("failed to take pty writer: {}", e))?; // Shared flag so the stdin thread knows when to stop let child_done = Arc::new(AtomicBool::new(false)); // Thread to forward stdin to PTY using poll(2) with 100ms timeout let stdin_handle = { let child_done = child_done.clone(); thread::spawn(move || { let stdin_fd = libc::STDIN_FILENO; let mut buf = [0u8; 1024]; loop { if child_done.load(Ordering::SeqCst) { break; } // Use poll(2) so we can periodically check if the child has exited let mut pfd = libc::pollfd { fd: stdin_fd, events: libc::POLLIN, revents: 0, }; let ret = unsafe { libc::poll(&mut pfd, 1, 100) }; if ret <= 0 { // timeout or error — loop back and check child_done continue; } if pfd.revents & libc::POLLIN != 0 { let n = unsafe { libc::read(stdin_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; if n <= 0 { break; } if pty_writer.write_all(&buf[..n as usize]).is_err() { break; } let _ = pty_writer.flush(); } if pfd.revents & (libc::POLLHUP | libc::POLLERR) != 0 { break; } } }) }; // Create log ingester for fire-and-forget streaming to daemon let ingester = ctx.as_ref().map(|c| { Arc::new(LogIngester::new( c.project_name.as_deref().unwrap_or("unknown"), &c.task_name, )) }); // Thread to read PTY output, tee to stdout, capture, and handle SIGWINCH let output_clone = output.clone(); let log_file_clone = log_file.clone(); let ingester_clone = ingester.clone(); let child_done_output = child_done.clone(); let output_handle = thread::spawn(move || { let mut stdout = std::io::stdout(); let mut buf = [0u8; 8192]; let mut line_buf = String::with_capacity(2048); let preferred_url = lifecycle_preferred_url(); let mut preferred_url_hint_emitted = false; loop { // Check for SIGWINCH and propagate resize to the PTY if SIGWINCH_RECEIVED.swap(false, Ordering::SeqCst) { if let Ok((cols, rows)) = crossterm::terminal::size() { let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }; let _ = master.resize(new_size); } } match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { let _ = stdout.write_all(&buf[..n]); let _ = stdout.flush(); if let Some(ref file) = log_file_clone { if let Ok(mut f) = file.lock() { let _ = f.write_all(&buf[..n]); let _ = f.flush(); } } let text = String::from_utf8_lossy(&buf[..n]); if let Ok(mut out) = output_clone.lock() { out.push_str(&text); } if let Some(ref ing) = ingester_clone { line_buf.push_str(&text); for_each_complete_line(&mut line_buf, |line| { maybe_emit_lifecycle_preferred_url_hint( &preferred_url, line, &mut preferred_url_hint_emitted, ); ing.send(line); }); } else { line_buf.push_str(&text); for_each_complete_line(&mut line_buf, |line| { maybe_emit_lifecycle_preferred_url_hint( &preferred_url, line, &mut preferred_url_hint_emitted, ); }); } } Err(_) => break, } // If child already exited, drain remaining output then stop if child_done_output.load(Ordering::SeqCst) { // One more non-blocking drain attempt break; } } // Flush remaining partial line if !line_buf.is_empty() { maybe_emit_lifecycle_preferred_url_hint( &preferred_url, &line_buf, &mut preferred_url_hint_emitted, ); if let Some(ref ing) = ingester_clone { ing.send(&line_buf); } } }); // Wait for the child process let exit_status = child .wait() .map_err(|e| anyhow::anyhow!("failed to wait on child: {}", e))?; // Signal threads that the child is done child_done.store(true, Ordering::SeqCst); // Wait for output thread (stdin thread will exit via poll + child_done flag) let _ = output_handle.join(); let _ = stdin_handle.join(); // _raw_guard drops here, restoring the terminal // Unregister the process if ctx.is_some() { if let Err(err) = running::unregister_process(pid) { tracing::debug!(?err, "failed to unregister process"); } } let collected = output .lock() .map(|s| s.clone()) .unwrap_or_else(|_| String::new()); // Convert portable_pty ExitStatus to std::process::ExitStatus let code = exit_status.exit_code(); let status = std::process::Command::new("sh") .arg("-c") .arg(format!("exit {}", code)) .status() .unwrap_or_else(|_| std::process::ExitStatus::default()); Ok((status, collected)) } fn run_command_with_pipes( mut cmd: Command, ctx: Option<TaskContext>, ) -> Result<(ExitStatus, String)> { let interactive = ctx.as_ref().map(|c| c.interactive).unwrap_or(false); // Interactive mode: inherit all stdio for TTY passthrough // NOTE: Do NOT create a new process group for interactive commands. // The child must remain in the foreground process group to read from the terminal. if interactive { let mut child = cmd .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() .with_context(|| "failed to spawn interactive command")?; let pid = child.id(); let pgid = running::get_pgid(pid).unwrap_or(pid); set_cleanup_process(pid, pgid); // Register the process if let Some(ref task_ctx) = ctx { let entry = RunningProcess { pid, pgid, task_name: task_ctx.task_name.clone(), command: task_ctx.command.clone(), started_at: running::now_ms(), config_path: task_ctx.config_path.clone(), project_root: task_ctx.project_root.clone(), used_flox: task_ctx.used_flox, project_name: task_ctx.project_name.clone(), }; if let Err(err) = running::register_process(entry) { tracing::warn!(?err, "failed to register running process"); } } let status = child.wait().with_context(|| "failed to wait on child")?; // Unregister on exit if let Err(err) = running::unregister_process(pid) { tracing::debug!(?err, "failed to unregister process"); } return Ok((status, String::new())); } // Create new process group on Unix for reliable child process management // (only for non-interactive commands) #[cfg(unix)] { use std::os::unix::process::CommandExt; cmd.process_group(0); } let mut child = cmd .stdin(Stdio::inherit()) // Allow user input for prompts .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .with_context(|| "failed to spawn command")?; let pid = child.id(); let pgid = running::get_pgid(pid).unwrap_or(pid); set_cleanup_process(pid, pgid); // Register the process if we have task context if let Some(ref task_ctx) = ctx { let entry = RunningProcess { pid, pgid, task_name: task_ctx.task_name.clone(), command: task_ctx.command.clone(), started_at: running::now_ms(), config_path: task_ctx.config_path.clone(), project_root: task_ctx.project_root.clone(), used_flox: task_ctx.used_flox, project_name: task_ctx.project_name.clone(), }; if let Err(err) = running::register_process(entry) { tracing::warn!(?err, "failed to register running process"); } } let output = Arc::new(Mutex::new(String::new())); // Set up optional log file for streaming output let (ctx, log_file) = match ctx { Some(mut c) => { let path = task_log_path(&c); if let Some(path) = path { if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } match OpenOptions::new().create(true).append(true).open(&path) { Ok(mut file) => { let header = format!( "\n--- {} | task:{} | cmd:{} ---\n", running::now_ms(), c.task_name, c.command ); let _ = file.write_all(header.as_bytes()); c.log_path = Some(path.clone()); (Some(c), Some(Arc::new(Mutex::new(file)))) } Err(err) => { if let Ok(mut buf) = output.lock() { buf.push_str(&format!("failed to open log file: {err}\n")); } (Some(c), None) } } } else { (Some(c), None) } } None => (None, None), }; // Create log ingester for fire-and-forget streaming to daemon let ingester = ctx.as_ref().map(|c| { Arc::new(LogIngester::new( c.project_name.as_deref().unwrap_or("unknown"), &c.task_name, )) }); let mut handles = Vec::new(); if let Some(stdout) = child.stdout.take() { handles.push(tee_stream( stdout, std::io::stdout(), output.clone(), log_file.clone(), ingester.clone(), )); } if let Some(stderr) = child.stderr.take() { handles.push(tee_stream( stderr, std::io::stderr(), output.clone(), log_file.clone(), ingester.clone(), )); } for handle in handles { let _ = handle.join(); } let status = child .wait() .with_context(|| "failed to wait for command completion")?; // Unregister the process if ctx.is_some() { if let Err(err) = running::unregister_process(pid) { tracing::warn!(?err, "failed to unregister process"); } } let collected = output .lock() .map(|s| s.clone()) .unwrap_or_else(|_| String::new()); Ok((status, collected)) } fn lifecycle_preferred_url() -> Option<String> { crate::lifecycle::runtime_preferred_url() } fn is_service_ready_line(line: &str) -> bool { let lower: Cow<'_, str> = if line.bytes().any(|b| b.is_ascii_uppercase()) { Cow::Owned(line.to_ascii_lowercase()) } else { Cow::Borrowed(line) }; (lower.contains("local:") && lower.contains("http://")) || lower.contains("ready on http://") || lower.contains("listening on http://") || lower.contains("listening at http://") } fn maybe_emit_lifecycle_preferred_url_hint( preferred_url: &Option<String>, line: &str, emitted: &mut bool, ) { if *emitted { return; } if !is_service_ready_line(line) { return; } let Some(url) = preferred_url.as_deref() else { return; }; println!("[flow][up] preferred URL: {url}"); *emitted = true; } fn for_each_complete_line(line_buf: &mut String, mut on_line: impl FnMut(&str)) { let mut start = 0usize; let mut drain_until = 0usize; while let Some(relative) = line_buf[start..].find('\n') { let end = start + relative; on_line(&line_buf[start..end]); start = end + 1; drain_until = start; } if drain_until > 0 { line_buf.drain(..drain_until); } } fn tee_stream<R, W>( mut reader: R, mut writer: W, buffer: Arc<Mutex<String>>, log_file: Option<Arc<Mutex<File>>>, ingester: Option<Arc<LogIngester>>, ) -> thread::JoinHandle<()> where R: Read + Send + 'static, W: Write + Send + 'static, { thread::spawn(move || { let mut chunk = [0u8; 4096]; let mut line_buf = String::with_capacity(2048); let preferred_url = lifecycle_preferred_url(); let mut preferred_url_hint_emitted = false; loop { let read = match reader.read(&mut chunk) { Ok(0) => break, Ok(n) => n, Err(_) => break, }; let _ = writer.write_all(&chunk[..read]); let _ = writer.flush(); if let Some(file) = log_file.as_ref() { if let Ok(mut f) = file.lock() { let _ = f.write_all(&chunk[..read]); let _ = f.flush(); } } let text = String::from_utf8_lossy(&chunk[..read]); if let Ok(mut buf) = buffer.lock() { buf.push_str(&text); } line_buf.push_str(&text); for_each_complete_line(&mut line_buf, |line| { maybe_emit_lifecycle_preferred_url_hint( &preferred_url, line, &mut preferred_url_hint_emitted, ); if let Some(ref ing) = ingester { ing.send(line); } }); } // Flush remaining partial line if !line_buf.is_empty() { maybe_emit_lifecycle_preferred_url_hint( &preferred_url, &line_buf, &mut preferred_url_hint_emitted, ); if let Some(ref ing) = ingester { ing.send(&line_buf); } } }) } fn reset_flox_env(project_root: &Path) -> Result<()> { let dir = project_root.join(".flox"); if dir.exists() { fs::remove_dir_all(&dir) .with_context(|| format!("failed to remove flox env at {}", dir.display()))?; } Ok(()) } fn flox_disabled_marker(project_root: &Path) -> PathBuf { project_root.join(".flox.disabled") } fn mark_flox_disabled(project_root: &Path, reason: &str) -> Result<()> { let marker = flox_disabled_marker(project_root); fs::write(&marker, reason).with_context(|| { format!( "failed to write flox disable marker at {}", marker.display() ) }) } #[derive(Debug, Default)] struct ResolvedDependencies { commands: Vec<String>, flox: Vec<(String, FloxInstallSpec)>, /// Task names that must run before this task. task_deps: Vec<String>, } fn resolve_task_dependencies(task: &TaskConfig, cfg: &Config) -> Result<ResolvedDependencies> { if task.dependencies.is_empty() { return Ok(ResolvedDependencies::default()); } let mut missing = Vec::new(); let mut resolved = ResolvedDependencies::default(); for dep_name in &task.dependencies { // First check if it's a [deps] entry if let Some(spec) = cfg.dependencies.get(dep_name) { match spec { config::DependencySpec::Single(cmd) => { // If value looks like a URL/path, use the key as binary name if cmd.contains('/') { resolved.commands.push(dep_name.clone()); } else { resolved.commands.push(cmd.clone()); } } config::DependencySpec::Multiple(cmds) => resolved.commands.extend(cmds.clone()), config::DependencySpec::Flox(pkg) => { resolved.flox.push((dep_name.clone(), pkg.clone())); } } continue; } // Check if it's a flox install if let Some(flox) = cfg.flox.as_ref().and_then(|f| f.install.get(dep_name)) { resolved.flox.push((dep_name.clone(), flox.clone())); continue; } // Check if it's a task name (for task ordering) if cfg.tasks.iter().any(|t| t.name == *dep_name) { resolved.task_deps.push(dep_name.clone()); continue; } missing.push(dep_name.as_str()); } if !missing.is_empty() { bail!( "task '{}' references unknown dependencies: {} (define them under [deps], [flox.install], or as a task name)", task.name, missing.join(", ") ); } Ok(resolved) } fn ensure_command_dependencies_available(commands: &[String]) -> Result<()> { if commands.is_empty() { return Ok(()); } for command in commands { which::which(command).with_context(|| dependency_error(command))?; } Ok(()) } fn dependency_error(command: &str) -> String { let mut msg = format!( "dependency '{}' not found in PATH. Install it or adjust the [dependencies] config.", command ); if let Some(extra) = dependency_help(command) { msg.push('\n'); msg.push_str(extra); } msg } fn dependency_help(command: &str) -> Option<&'static str> { match command { "fast" => { Some("Get the fast CLI from https://github.com/nikivdev/fast and ensure it is on PATH.") } _ => None, } } fn collect_flox_packages( cfg: &Config, deps: &[(String, FloxInstallSpec)], ) -> Vec<(String, FloxInstallSpec)> { let mut merged = std::collections::BTreeMap::new(); if let Some(flox) = &cfg.flox { for (name, spec) in &flox.install { merged.insert(name.clone(), spec.clone()); } } for (name, spec) in deps { merged.insert(name.clone(), spec.clone()); } merged.into_iter().collect() } fn delegate_task_to_hub( task: &TaskConfig, deps: &ResolvedDependencies, workdir: &Path, host: IpAddr, port: u16, command: &str, ) -> Result<()> { ensure_hub_running(host, port)?; let url = format_task_submit_url(host, port); let client = Client::builder() .timeout(Duration::from_secs(5)) .build() .context("failed to construct HTTP client for hub delegation")?; let flox_specs: Vec<_> = deps .flox .iter() .map(|(name, spec)| json!({ "name": name, "spec": spec })) .collect(); let payload = json!({ "task": { "name": task.name, "command": command, "dependencies": { "commands": deps.commands, "flox": flox_specs, }, }, "cwd": workdir.to_string_lossy(), "flow_version": env!("CARGO_PKG_VERSION"), }); let resp = client.post(&url).json(&payload).send().with_context(|| { format!( "failed to submit task to hub at {}", format_addr(host, port) ) })?; let status = resp.status(); if status.is_success() { println!( "Delegated task '{}' to hub at {}", task.name, format_addr(host, port) ); Ok(()) } else { let body = resp.text().unwrap_or_default(); bail!( "hub returned {} while delegating task '{}': {}", status, task.name, body ); } } fn ensure_hub_running(host: IpAddr, port: u16) -> Result<()> { let opts = HubOpts { host, port, config: None, no_ui: true, docs_hub: false, }; let cmd = HubCommand { opts, action: Some(HubAction::Start), }; hub::run(cmd) } fn format_addr(host: IpAddr, port: u16) -> String { match host { IpAddr::V4(_) => format!("http://{host}:{port}"), IpAddr::V6(_) => format!("http://[{host}]:{port}"), } } fn format_task_submit_url(host: IpAddr, port: u16) -> String { match host { IpAddr::V4(_) => format!("http://{host}:{port}/tasks/run"), IpAddr::V6(_) => format!("http://[{host}]:{port}/tasks/run"), } } #[cfg(test)] mod tests { use super::*; use crate::config::{DependencySpec, FloxConfig, TaskResolutionConfig}; use std::collections::HashMap; use std::path::Path; #[test] fn formats_task_lines_with_descriptions() { let tasks = vec![ TaskConfig { name: "lint".to_string(), command: "golangci-lint run".to_string(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: Some("Run lint checks".to_string()), shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, TaskConfig { name: "test".to_string(), command: "gotestsum ./...".to_string(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, ]; let lines = format_task_lines(&tasks); assert_eq!( lines, vec![ " 1. lint – golangci-lint run".to_string(), " Run lint checks".to_string(), " 2. test – gotestsum ./...".to_string(), ] ); } fn discovered_task(scope: &str, relative_dir: &str, name: &str) -> discover::DiscoveredTask { discover::DiscoveredTask { task: TaskConfig { name: name.to_string(), command: format!("echo {}", name), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, config_path: PathBuf::from(format!("{}/flow.toml", scope)), relative_dir: relative_dir.to_string(), depth: if relative_dir.is_empty() { 0 } else { 1 }, scope: scope.to_string(), scope_aliases: vec![scope.to_ascii_lowercase()], } } #[test] fn parse_scoped_selector_supports_colon_and_slash() { assert_eq!( parse_scoped_selector("mobile:dev"), Some(("mobile".to_string(), "dev".to_string())) ); assert_eq!( parse_scoped_selector("mobile/dev"), Some(("mobile".to_string(), "dev".to_string())) ); assert!(parse_scoped_selector("dev").is_none()); } #[test] fn resolve_ambiguous_task_match_uses_route_then_preferred_scope() { let mobile = discovered_task("mobile", "mobile", "dev"); let root = discovered_task("root", "", "dev"); let matches = vec![&mobile, &root]; let mut cfg = Config::default(); cfg.task_resolution = Some(TaskResolutionConfig { preferred_scopes: vec!["root".to_string()], routes: HashMap::from([(String::from("dev"), String::from("mobile"))]), warn_on_implicit_scope: Some(false), }); let selected = resolve_ambiguous_task_match("dev", &matches, cfg.task_resolution.as_ref()) .expect("route should pick"); assert_eq!(selected.scope, "mobile"); cfg.task_resolution = Some(TaskResolutionConfig { preferred_scopes: vec!["root".to_string()], routes: HashMap::new(), warn_on_implicit_scope: Some(false), }); let selected = resolve_ambiguous_task_match("dev", &matches, cfg.task_resolution.as_ref()) .expect("preferred scope should pick"); assert_eq!(selected.scope, "root"); } #[test] fn select_discovered_task_allows_exact_names_with_scope_delimiters() { let scoped = discovered_task("mobile", "mobile", "run"); let exact = discovered_task("root", "", "mobile:dev"); let discovery = discover::DiscoveryResult { tasks: vec![scoped, exact], root_config: None, root_task_resolution: None, }; let selected = select_discovered_task(&discovery, "mobile:dev") .expect("selection should succeed") .expect("exact task should resolve"); assert_eq!(selected.scope, "root"); assert_eq!(selected.task.name, "mobile:dev"); } #[test] fn format_discovered_task_lines_prefixes_scope() { let entries = vec![discovered_task("mobile", "mobile", "dev")]; let ai_entries: Vec<ai_tasks::DiscoveredAiTask> = Vec::new(); let lines = format_discovered_task_lines(&entries, &ai_entries); assert!(lines[0].contains("mobile:dev")); } #[test] fn run_rejects_empty_commands() { let task = TaskConfig { name: "empty".into(), command: "".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }; let empty_args: Vec<String> = Vec::new(); let err = execute_task( &task, Path::new("flow.toml"), Path::new("."), String::new(), None, &[], false, "", &empty_args, &task.name, ) .unwrap_err(); assert!( err.to_string().contains("empty command"), "unexpected error: {err:?}" ); } #[test] fn collects_dependency_commands() { let mut cfg = Config::default(); cfg.dependencies .insert("fast".into(), DependencySpec::Single("fast".into())); cfg.dependencies.insert( "toolkit".into(), DependencySpec::Multiple(vec!["rg".into(), "fd".into()]), ); let task = TaskConfig { name: "ci".into(), command: "ci".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: vec!["fast".into(), "toolkit".into()], description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }; let resolved = resolve_task_dependencies(&task, &cfg).expect("dependencies should resolve"); assert_eq!( resolved.commands, vec!["fast".to_string(), "rg".to_string(), "fd".to_string()] ); assert!(resolved.flox.is_empty()); } #[test] fn collects_flox_dependencies_from_dependency_table() { let mut cfg = Config::default(); cfg.dependencies.insert( "ripgrep".into(), DependencySpec::Flox(FloxInstallSpec { pkg_path: "ripgrep".into(), pkg_group: None, version: None, systems: None, priority: None, }), ); let task = TaskConfig { name: "search".into(), command: "rg TODO".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: vec!["ripgrep".into()], description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }; let resolved = resolve_task_dependencies(&task, &cfg).expect("dependencies should resolve"); assert!(resolved.commands.is_empty()); assert_eq!(resolved.flox.len(), 1); assert_eq!(resolved.flox[0].0, "ripgrep"); assert_eq!(resolved.flox[0].1.pkg_path, "ripgrep"); } #[test] fn collects_flox_dependencies_from_flox_config() { let mut cfg = Config::default(); let mut install = std::collections::HashMap::new(); install.insert( "node".to_string(), FloxInstallSpec { pkg_path: "nodejs".into(), pkg_group: None, version: None, systems: None, priority: None, }, ); cfg.flox = Some(FloxConfig { install }); let task = TaskConfig { name: "dev".into(), command: "npm start".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: vec!["node".into()], description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }; let resolved = resolve_task_dependencies(&task, &cfg).expect("dependencies should resolve"); assert!(resolved.commands.is_empty()); assert_eq!(resolved.flox.len(), 1); assert_eq!(resolved.flox[0].0, "node"); assert_eq!(resolved.flox[0].1.pkg_path, "nodejs"); } #[test] fn errors_on_missing_dependencies() { let cfg = Config::default(); let task = TaskConfig { name: "ci".into(), command: "ci".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: vec!["unknown".into()], description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }; let err = resolve_task_dependencies(&task, &cfg).unwrap_err(); assert!( err.to_string().contains("references unknown dependencies"), "unexpected error: {err:?}" ); } #[test] fn errors_when_dependency_not_declared_in_table() { let mut cfg = Config::default(); cfg.dependencies .insert("fast".into(), DependencySpec::Single("fast".into())); let task = TaskConfig { name: "ci".into(), command: "ci".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: vec!["unknown".into()], description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }; let err = resolve_task_dependencies(&task, &cfg).unwrap_err(); assert!( err.to_string().contains("references unknown dependencies"), "unexpected error: {err:?}" ); } #[test] fn find_task_matches_shortcuts_and_abbreviations() { let mut cfg = Config::default(); cfg.tasks = vec![ TaskConfig { name: "deploy-cli-release".into(), command: "echo deploy".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: vec!["dcr-alias".into()], interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, TaskConfig { name: "dev-hub".into(), command: "echo dev".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, ]; let task = find_task(&cfg, "dcr-alias").expect("shortcut should resolve"); assert_eq!(task.name, "deploy-cli-release"); let task = find_task(&cfg, "dcr").expect("abbreviation should resolve"); assert_eq!(task.name, "deploy-cli-release"); let task = find_task(&cfg, "dev-hub").expect("exact match should resolve"); assert_eq!(task.name, "dev-hub"); let task = find_task(&cfg, "DH").expect("case-insensitive match should resolve"); assert_eq!(task.name, "dev-hub"); } #[test] fn ambiguous_abbreviations_do_not_match() { let mut cfg = Config::default(); cfg.tasks = vec![ TaskConfig { name: "deploy-cli-release".into(), command: "echo deploy".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, TaskConfig { name: "deploy-core-runner".into(), command: "echo runner".into(), delegate_to_hub: false, activate_on_cd_to_root: false, dependencies: Vec::new(), description: None, shortcuts: Vec::new(), interactive: false, confirm_on_match: false, on_cancel: None, output_file: None, }, ]; assert!( find_task(&cfg, "dcr").is_none(), "abbreviation should be ambiguous" ); } #[test] fn detects_command_arg_references() { // Should detect $@, $*, $1, $2, etc. assert!(command_references_args("echo $@")); assert!(command_references_args("echo $*")); assert!(command_references_args("echo $1")); assert!(command_references_args("echo $9")); assert!(command_references_args("bash -c 'echo $@' --")); assert!(command_references_args("script.sh \"$1\" \"$2\"")); assert!(command_references_args("echo ${1}")); assert!(command_references_args("echo ${@}")); // Should not detect other $ variables assert!(!command_references_args("echo $HOME")); assert!(!command_references_args("echo $0")); // $0 is script name, not arg assert!(!command_references_args("echo ${HOME}")); assert!(!command_references_args("echo $$")); // PID assert!(!command_references_args("echo $?")); // exit code assert!(!command_references_args( "source .env && bun script.ts --delete" )); } } ================================================ FILE: src/terminal.rs ================================================ use std::{ env, fs, path::{Path, PathBuf}, process::Command, }; use anyhow::{Context, Result, bail}; use which::which; use crate::config::OptionsConfig; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; const LOG_DIR_SUFFIX: &str = ".flow/tmux-logs"; const META_DIR_SUFFIX: &str = ".flow/tty-meta"; const SCRIPT_PATH_SUFFIX: &str = ".config/flow/tmux-enable-tracing.sh"; const FISH_CONF_SUFFIX: &str = ".config/fish/conf.d/flow-trace.fish"; pub fn maybe_enable_terminal_tracing(options: &OptionsConfig) { if !options.trace_terminal_io { return; } if let Err(err) = enforce_tmux_logging() { tracing::warn!(?err, "failed to enable tmux-based terminal tracing"); } if let Err(err) = install_fish_hooks() { tracing::warn!(?err, "failed to install fish tracing hooks"); } } fn enforce_tmux_logging() -> Result<()> { if which("tmux").is_err() { tracing::info!("tmux not found on PATH; skipping terminal IO tracing"); return Ok(()); } let home = home_dir(); let log_dir = home.join(LOG_DIR_SUFFIX); fs::create_dir_all(&log_dir) .with_context(|| format!("failed to create tmux log dir {}", log_dir.display()))?; let script_path = home.join(SCRIPT_PATH_SUFFIX); write_enable_script(&script_path, &log_dir)?; run_tmux(&["start-server"], "start tmux server for tracing")?; install_hooks(&script_path)?; prime_existing_panes(&script_path)?; tracing::info!(dir = %log_dir.display(), "tmux terminal tracing enabled"); Ok(()) } fn install_fish_hooks() -> Result<()> { if which("fish").is_err() { tracing::debug!("fish not found on PATH; skipping fish hook installation"); return Ok(()); } let home = home_dir(); let meta_dir = home.join(META_DIR_SUFFIX); fs::create_dir_all(&meta_dir) .with_context(|| format!("failed to create fish meta dir {}", meta_dir.display()))?; let conf_path = home.join(FISH_CONF_SUFFIX); write_fish_conf(&conf_path, &meta_dir)?; Ok(()) } fn install_hooks(script_path: &Path) -> Result<()> { let script_cmd = format!("run-shell {}", sh_quote(script_path)); for hook in ["pane-add", "client-session-changed", "session-created"] { run_tmux( &["set-hook", "-g", hook, &script_cmd], "install tmux tracing hook", )?; } Ok(()) } fn prime_existing_panes(script_path: &Path) -> Result<()> { let output = Command::new("tmux") .args(["list-panes", "-a", "-F", "#{pane_id}"]) .output(); let output = match output { Ok(out) if out.status.success() => out, Ok(_) => return Ok(()), // No panes yet; hooks will handle future ones. Err(err) => { tracing::warn!(?err, "unable to list tmux panes for tracing bootstrap"); return Ok(()); } }; let script_cmd = sh_quote(script_path); for pane in String::from_utf8_lossy(&output.stdout).lines() { let pane = pane.trim(); if pane.is_empty() { continue; } let run_shell_cmd = format!("{script_cmd} {pane}"); run_tmux(&["run-shell", &run_shell_cmd], "prime tmux pane tracing")?; } Ok(()) } fn write_enable_script(script_path: &Path, log_dir: &Path) -> Result<()> { if let Some(parent) = script_path.parent() { fs::create_dir_all(parent).with_context(|| { format!( "failed to create directory for tmux tracing script {}", parent.display() ) })?; } let contents = format!( r#"#!/bin/sh set -e LOG_DIR={log_dir} mkdir -p "$LOG_DIR" TARGET="${{1:-!}}" tmux pipe-pane -o -t "$TARGET" "cat >>${{LOG_DIR}}/pane-#{{session_name}}-#{{window_index}}-#{{pane_index}}.log" "#, log_dir = sh_quote(log_dir) ); fs::write(script_path, contents).with_context(|| { format!( "failed to write tmux tracing helper to {}", script_path.display() ) })?; #[cfg(unix)] fs::set_permissions(script_path, fs::Permissions::from_mode(0o755)).with_context(|| { format!( "failed to mark tmux tracing script executable at {}", script_path.display() ) })?; Ok(()) } fn write_fish_conf(conf_path: &Path, meta_dir: &Path) -> Result<()> { const CONTENTS: &str = r#"if status --is-interactive if not set -q TMUX if not set -q FLOW_SKIP_AUTO_TMUX if type -q tmux set -l __flow_trace_tmux_session "flow" if set -q FLOW_AUTO_TMUX_SESSION set __flow_trace_tmux_session $FLOW_AUTO_TMUX_SESSION end exec tmux new-session -A -s $__flow_trace_tmux_session end end end end set -g __flow_trace_meta_dir "%META_DIR%" mkdir -p $__flow_trace_meta_dir function __flow_trace_preexec --on-event fish_preexec set -l id (uuidgen) set -gx FLOW_CMD_ID $id set -l ts (date -Ins) set -l cmd (string join ' ' $argv) set -l pane (set -q TMUX_PANE; and echo $TMUX_PANE; or echo "nopane") set -l cwd (pwd) set -l cwd_b64 (printf "%s" $cwd | base64) set -l cmd_b64 (printf "%s" $cmd | base64) printf "\e]133;A;flow-cmd-start;%s\a" $id printf "start %s %s %s %s\n" $ts $id $cwd_b64 $cmd_b64 >> $__flow_trace_meta_dir/$pane.log end function __flow_trace_postexec --on-event fish_postexec set -l ts (date -Ins) set -l pane (set -q TMUX_PANE; and echo $TMUX_PANE; or echo "nopane") printf "\e]133;B;flow-cmd-end;%s;%s\a" $FLOW_CMD_ID $status printf "end %s %s %s\n" $ts $FLOW_CMD_ID $status >> $__flow_trace_meta_dir/$pane.log end "#; let rendered = CONTENTS.replace("%META_DIR%", &meta_dir.to_string_lossy()); if let Some(parent) = conf_path.parent() { fs::create_dir_all(parent).with_context(|| { format!( "failed to create directory for fish tracing conf {}", parent.display() ) })?; } // Avoid rewriting if unchanged to keep user shells happy. if let Ok(existing) = fs::read_to_string(conf_path) { if existing == rendered { return Ok(()); } } fs::write(conf_path, rendered).with_context(|| { format!( "failed to write fish tracing hooks to {}", conf_path.display() ) }) } fn run_tmux(args: &[&str], context: &str) -> Result<()> { let status = Command::new("tmux") .args(args) .status() .with_context(|| format!("failed to execute tmux to {context}"))?; if status.success() { Ok(()) } else { bail!( "tmux exited with status {} while attempting to {context}", status.code().unwrap_or(-1) ); } } fn sh_quote(path: &Path) -> String { let value = path.to_string_lossy(); let escaped = value.replace('\'', r"'\''"); format!("'{escaped}'") } fn home_dir() -> PathBuf { env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } ================================================ FILE: src/todo.rs ================================================ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use chrono::Utc; use serde::{Deserialize, Serialize}; use sha1::{Digest, Sha1}; use uuid::Uuid; use crate::ai; use crate::cli::{TodoAction, TodoCommand, TodoStatusArg}; #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct TodoItem { pub id: String, pub title: String, pub status: String, pub created_at: String, pub updated_at: Option<String>, pub note: Option<String>, pub session: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub external_ref: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub priority: Option<String>, } pub fn run(cmd: TodoCommand) -> Result<()> { match cmd.action { None | Some(TodoAction::Bike) => open_bike(), Some(TodoAction::Add { title, note, session, no_session, status, }) => add( &title, note.as_deref(), session.as_deref(), no_session, status, ), Some(TodoAction::List { all }) => list(all), Some(TodoAction::Done { id }) => set_status(&id, TodoStatusArg::Completed), Some(TodoAction::Edit { id, title, status, note, }) => edit(&id, title.as_deref(), status, note), Some(TodoAction::Remove { id }) => remove(&id), } } fn open_bike() -> Result<()> { let root = project_root(); let project_name = root .file_name() .and_then(|name| name.to_str()) .map(|name| name.to_string()) .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| "project".to_string()); let dir = root.join(".ai").join("todos"); let path = dir.join(format!("{}.bike", project_name)); fs::create_dir_all(&dir)?; let needs_init = match fs::read_to_string(&path) { Ok(content) => !looks_like_bike(&content), Err(_) => true, }; if needs_init { let content = render_bike_template(&project_name); fs::write(&path, content)?; } let bike_app = Path::new("/System/Volumes/Data/Applications/Bike.app"); if !bike_app.exists() { bail!("Bike.app not found at {}", bike_app.display()); } let status = Command::new("open") .arg("-a") .arg(bike_app) .arg(&path) .status() .context("failed to launch Bike.app")?; if !status.success() { bail!("Bike.app failed to open {}", path.display()); } Ok(()) } fn looks_like_bike(content: &str) -> bool { let trimmed = content.trim_start(); if !trimmed.starts_with("<?xml") { return false; } let lower = trimmed.to_ascii_lowercase(); lower.contains("<html") && lower.contains("<body") && lower.contains("<ul") } fn render_bike_template(project_name: &str) -> String { let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let ul_id = format!("_{}", Uuid::new_v4().simple()); let li_id = Uuid::new_v4().simple().to_string(); format!( "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<html>\n <head>\n <meta charset=\"utf-8\"/>\n </head>\n <body>\n <ul id=\"{}\" data-created=\"{}\" data-modified=\"{}\">\n <li id=\"{}\" data-created=\"{}\" data-modified=\"{}\">\n <p>{}</p>\n </li>\n </ul>\n </body>\n</html>\n", ul_id, now, now, li_id, now, now, project_name ) } fn add( title: &str, note: Option<&str>, session: Option<&str>, no_session: bool, status: TodoStatusArg, ) -> Result<()> { let trimmed = title.trim(); if trimmed.is_empty() { bail!("todo title cannot be empty"); } let (path, mut items) = load_items()?; let session_ref = resolve_session_ref(session, no_session)?; let now = Utc::now().to_rfc3339(); let item = TodoItem { id: Uuid::new_v4().simple().to_string(), title: trimmed.to_string(), status: status_to_string(status).to_string(), created_at: now, updated_at: None, note: note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()), session: session_ref, external_ref: None, priority: None, }; items.push(item.clone()); save_items(&path, &items)?; println!("✓ Added {} [{}]", item.id, item.title); Ok(()) } fn list(show_all: bool) -> Result<()> { let (_path, items) = load_items()?; if items.is_empty() { println!("No todos yet."); return Ok(()); } let mut count = 0; for item in &items { if !show_all && item.status == status_to_string(TodoStatusArg::Completed) { continue; } count += 1; println!("[{}] {} {}", item.status, item.id, item.title); if let Some(note) = &item.note { println!(" - {}", note); } if let Some(session) = &item.session { println!(" @ {}", session); } } if count == 0 { println!("No active todos."); } Ok(()) } fn edit( id: &str, title: Option<&str>, status: Option<TodoStatusArg>, note: Option<String>, ) -> Result<()> { let (path, mut items) = load_items()?; let idx = find_item_index(&items, id)?; let item_id = { let item = &mut items[idx]; if let Some(title) = title { let title = title.trim(); if !title.is_empty() { item.title = title.to_string(); } } if let Some(status) = status { item.status = status_to_string(status).to_string(); } if let Some(note) = note { let note = note.trim().to_string(); item.note = if note.is_empty() { None } else { Some(note) }; } item.updated_at = Some(Utc::now().to_rfc3339()); item.id.clone() }; save_items(&path, &items)?; println!("✓ Updated {}", item_id); Ok(()) } fn set_status(id: &str, status: TodoStatusArg) -> Result<()> { let (path, mut items) = load_items()?; let idx = find_item_index(&items, id)?; let (item_id, item_status) = { let item = &mut items[idx]; item.status = status_to_string(status).to_string(); item.updated_at = Some(Utc::now().to_rfc3339()); (item.id.clone(), item.status.clone()) }; save_items(&path, &items)?; println!("✓ {} -> {}", item_id, item_status); Ok(()) } fn remove(id: &str) -> Result<()> { let (path, mut items) = load_items()?; let idx = find_item_index(&items, id)?; let item = items.remove(idx); save_items(&path, &items)?; println!("✓ Removed {}", item.id); Ok(()) } fn status_to_string(status: TodoStatusArg) -> &'static str { match status { TodoStatusArg::Pending => "pending", TodoStatusArg::InProgress => "in_progress", TodoStatusArg::Completed => "completed", TodoStatusArg::Blocked => "blocked", } } fn load_items() -> Result<(PathBuf, Vec<TodoItem>)> { let root = project_root(); let dir = root.join(".ai").join("todos"); let path = dir.join("todos.json"); if !path.exists() { return Ok((path, Vec::new())); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; if content.trim().is_empty() { return Ok((path, Vec::new())); } let items = serde_json::from_str(&content) .with_context(|| format!("failed to parse {}", path.display()))?; Ok((path, items)) } pub(crate) fn load_items_at_root(root: &Path) -> Result<(PathBuf, Vec<TodoItem>)> { let dir = root.join(".ai").join("todos"); let path = dir.join("todos.json"); if !path.exists() { return Ok((path, Vec::new())); } let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; if content.trim().is_empty() { return Ok((path, Vec::new())); } let items = serde_json::from_str(&content) .with_context(|| format!("failed to parse {}", path.display()))?; Ok((path, items)) } pub(crate) fn save_items(path: &Path, items: &[TodoItem]) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = serde_json::to_string_pretty(items)?; fs::write(path, content)?; Ok(()) } fn todo_title_compact(title: &str) -> String { let trimmed = title.trim().trim_start_matches('-').trim(); let max_len = 120; let mut out = String::new(); let mut count = 0; for ch in trimmed.chars() { if count >= max_len { out.push_str("..."); break; } out.push(ch); count += 1; } if out.is_empty() { "todo".to_string() } else { out } } fn external_ref_for_review_issue(commit_sha: &str, issue: &str) -> String { let mut hasher = Sha1::new(); hasher.update(commit_sha.trim().as_bytes()); hasher.update(b":"); hasher.update(issue.trim().as_bytes()); let hex = hex::encode(hasher.finalize()); let short = hex.get(..12).unwrap_or(&hex); format!("flow-review-issue-{}", short) } /// Infer priority from issue text using keyword heuristics. pub(crate) fn parse_priority_from_issue(issue: &str) -> String { let lower = issue.to_lowercase(); if lower.contains("secret") || lower.contains("credential") || lower.contains("api key") || lower.contains("injection") || lower.contains("vulnerability") || lower.contains("security") { return "P1".to_string(); } if lower.contains("crash") || lower.contains("data loss") || lower.contains("race condition") || lower.contains("memory leak") || lower.contains("buffer overflow") { return "P2".to_string(); } if lower.contains("bug") || lower.contains("error handling") || lower.contains("panic") || lower.contains("unwrap") || lower.contains("missing validation") { return "P3".to_string(); } "P4".to_string() } /// Load only review todos (those with external_ref starting with "flow-review-issue-"). pub(crate) fn load_review_todos(repo_root: &Path) -> Result<Vec<TodoItem>> { let (_path, items) = load_items_at_root(repo_root)?; Ok(items .into_iter() .filter(|item| { item.external_ref .as_deref() .map(|r| r.starts_with("flow-review-issue-")) .unwrap_or(false) }) .collect()) } /// Count open (non-completed) review todos by priority. /// Returns (p1, p2, p3, p4, total). pub(crate) fn count_open_review_todos_by_priority( repo_root: &Path, ) -> Result<(usize, usize, usize, usize, usize)> { let items = load_review_todos(repo_root)?; let (mut p1, mut p2, mut p3, mut p4) = (0, 0, 0, 0); for item in &items { if item.status == "completed" { continue; } match item.priority.as_deref().unwrap_or("P4") { "P1" => p1 += 1, "P2" => p2 += 1, "P3" => p3 += 1, _ => p4 += 1, } } let total = p1 + p2 + p3 + p4; Ok((p1, p2, p3, p4, total)) } /// Record review issues as project-scoped todos under `.ai/todos/todos.json`. /// Returns ids for created items (deduplicated by `external_ref`). pub fn record_review_issues_as_todos( repo_root: &Path, commit_sha: &str, issues: &[String], summary: Option<&str>, model_label: &str, ) -> Result<Vec<String>> { if issues.is_empty() { return Ok(Vec::new()); } let (path, mut items) = load_items_at_root(repo_root)?; let mut existing_refs = std::collections::HashSet::new(); for item in &items { if let Some(r) = item .external_ref .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { existing_refs.insert(r.to_string()); } } let mut created_ids = Vec::new(); let now = Utc::now().to_rfc3339(); let summary = summary.map(|s| s.trim()).filter(|s| !s.is_empty()); for issue in issues { let ext = external_ref_for_review_issue(commit_sha, issue); if existing_refs.contains(&ext) { continue; } let title = todo_title_compact(issue); let mut note = String::new(); note.push_str("Source: flow review\n"); note.push_str("Commit: "); note.push_str(commit_sha.trim()); note.push('\n'); note.push_str("Model: "); note.push_str(model_label.trim()); note.push('\n'); if let Some(summary) = summary { note.push_str("Review summary: "); note.push_str(summary); note.push('\n'); } note.push('\n'); note.push_str(issue.trim()); let id = Uuid::new_v4().simple().to_string(); let priority = parse_priority_from_issue(issue); items.push(TodoItem { id: id.clone(), title, status: status_to_string(TodoStatusArg::Pending).to_string(), created_at: now.clone(), updated_at: None, note: Some(note), session: None, external_ref: Some(ext.clone()), priority: Some(priority), }); existing_refs.insert(ext); created_ids.push(id); } if !created_ids.is_empty() { save_items(&path, &items)?; } Ok(created_ids) } /// Mark review-timeout follow-up todos as completed for the given todo ids. /// Returns number of todos updated. pub fn complete_review_timeout_todos(repo_root: &Path, ids: &[String]) -> Result<usize> { if ids.is_empty() { return Ok(0); } let targets: std::collections::HashSet<String> = ids .iter() .map(|id| id.trim().to_string()) .filter(|id| !id.is_empty()) .collect(); if targets.is_empty() { return Ok(0); } let (path, mut items) = load_items_at_root(repo_root)?; let mut updated = 0usize; let now = Utc::now().to_rfc3339(); for item in &mut items { if !targets.contains(&item.id) { continue; } if !is_review_timeout_followup(item) { continue; } if item.status == status_to_string(TodoStatusArg::Completed) { continue; } item.status = status_to_string(TodoStatusArg::Completed).to_string(); item.updated_at = Some(now.clone()); updated += 1; } if updated > 0 { save_items(&path, &items)?; } Ok(updated) } /// Count review todos by ids that are still not completed. pub fn count_open_todos(repo_root: &Path, ids: &[String]) -> Result<usize> { if ids.is_empty() { return Ok(0); } let targets: std::collections::HashSet<String> = ids .iter() .map(|id| id.trim().to_string()) .filter(|id| !id.is_empty()) .collect(); if targets.is_empty() { return Ok(0); } let (_path, items) = load_items_at_root(repo_root)?; let mut open = 0usize; for item in items { if !targets.contains(&item.id) { continue; } if item.status != status_to_string(TodoStatusArg::Completed) { open += 1; } } Ok(open) } fn is_review_timeout_followup(item: &TodoItem) -> bool { let title = item.title.trim().to_lowercase(); if title.starts_with("re-run review:") || title.contains("review timed out") { return true; } item.note .as_deref() .map(|n| n.to_lowercase().contains("review timed out")) .unwrap_or(false) } pub(crate) fn find_item_index(items: &[TodoItem], id: &str) -> Result<usize> { let mut matches = Vec::new(); for (idx, item) in items.iter().enumerate() { if item.id == id || item.id.starts_with(id) { matches.push(idx); } } match matches.len() { 0 => bail!("Todo '{}' not found", id), 1 => Ok(matches[0]), _ => bail!("Todo id '{}' is ambiguous", id), } } fn resolve_session_ref(session: Option<&str>, no_session: bool) -> Result<Option<String>> { if no_session { return Ok(None); } if let Some(session) = session { let trimmed = session.trim(); return Ok(if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }); } let root = project_root(); match ai::get_latest_session_ref_for_path(&root)? { Some(latest) => Ok(Some(latest)), None => Ok(None), } } pub(crate) fn project_root() -> PathBuf { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); if let Some(flow_path) = find_flow_toml(&cwd) { return flow_path.parent().unwrap_or(&cwd).to_path_buf(); } cwd } fn find_flow_toml(start: &PathBuf) -> Option<PathBuf> { let mut current = start.clone(); loop { let candidate = current.join("flow.toml"); if candidate.exists() { return Some(candidate); } if !current.pop() { return None; } } } ================================================ FILE: src/tools.rs ================================================ //! AI tools management - execute TypeScript tools via localcode/bun. //! //! Tools are stored in .ai/tools/<name>.ts use std::fs; use std::path::PathBuf; use std::process::Command; use anyhow::{Context, Result, bail}; use crate::cli::{ToolsAction, ToolsCommand}; /// Run the tools subcommand. pub fn run(cmd: ToolsCommand) -> Result<()> { let action = cmd.action.unwrap_or(ToolsAction::List); match action { ToolsAction::List => list_tools()?, ToolsAction::Run { name, args } => run_tool(&name, args)?, ToolsAction::New { name, description, ai, } => new_tool(&name, description.as_deref(), ai)?, ToolsAction::Edit { name } => edit_tool(&name)?, ToolsAction::Remove { name } => remove_tool(&name)?, } Ok(()) } /// Get the tools directory for the current project. fn get_tools_dir() -> Result<PathBuf> { let cwd = std::env::current_dir().context("failed to get current directory")?; Ok(cwd.join(".ai").join("tools")) } /// Find the localcode binary (our opencode fork). fn find_localcode() -> Option<PathBuf> { // Check ~/.local/bin/localcode first if let Some(home) = dirs::home_dir() { let local_bin = home.join(".local/bin/localcode"); if local_bin.exists() { return Some(local_bin); } } // Fall back to PATH which::which("localcode").ok() } /// List all tools in the project. fn list_tools() -> Result<()> { let tools_dir = get_tools_dir()?; if !tools_dir.exists() { println!("No tools found. Create one with: f tools new <name>"); return Ok(()); } let entries = fs::read_dir(&tools_dir).context("failed to read tools directory")?; let mut tools: Vec<(String, Option<String>)> = Vec::new(); for entry in entries { let entry = entry?; let path = entry.path(); if path.extension().map_or(false, |e| e == "ts") { let name = path .file_stem() .and_then(|n| n.to_str()) .unwrap_or("") .to_string(); let description = parse_tool_description(&path); tools.push((name, description)); } } if tools.is_empty() { println!("No tools found. Create one with: f tools new <name>"); return Ok(()); } tools.sort_by(|a, b| a.0.cmp(&b.0)); println!("Tools in .ai/tools/:\n"); for (name, desc) in tools { if let Some(d) = desc { println!(" {} - {}", name, d); } else { println!(" {}", name); } } println!("\nRun with: f tools run <name>"); Ok(()) } /// Parse description from first comment line in a .ts file. fn parse_tool_description(path: &PathBuf) -> Option<String> { let content = fs::read_to_string(path).ok()?; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("// ") { return Some(trimmed.trim_start_matches("// ").to_string()); } if trimmed.starts_with("///") { return Some(trimmed.trim_start_matches("///").trim().to_string()); } // Skip empty lines at the top if !trimmed.is_empty() && !trimmed.starts_with("//") { break; } } None } /// Run a tool via bun. fn run_tool(name: &str, args: Vec<String>) -> Result<()> { let tools_dir = get_tools_dir()?; let tool_file = tools_dir.join(format!("{}.ts", name)); if !tool_file.exists() { bail!( "Tool '{}' not found. Create it with: f tools new {}", name, name ); } let status = Command::new("bun") .arg("run") .arg(&tool_file) .args(&args) .status() .context("failed to run bun")?; if !status.success() { bail!("Tool '{}' exited with status: {}", name, status); } Ok(()) } /// Create a new tool. fn new_tool(name: &str, description: Option<&str>, use_ai: bool) -> Result<()> { let tools_dir = get_tools_dir()?; fs::create_dir_all(&tools_dir).context("failed to create tools directory")?; let tool_file = tools_dir.join(format!("{}.ts", name)); if tool_file.exists() { bail!("Tool '{}' already exists", name); } if use_ai { // Use localcode to generate the tool let localcode = find_localcode(); if localcode.is_none() { bail!( "localcode not found. Install it with:\n \ cd <opencode-repo> && flow link" ); } let desc = description.unwrap_or(name); let prompt = format!( "Create a TypeScript tool for Bun called '{}' that: {}\n\n\ Requirements:\n\ - Use Bun APIs (Bun.$, Bun.file, etc.)\n\ - Add a description comment at the top\n\ - Handle CLI args via Bun.argv\n\ - Save to: {}", name, desc, tool_file.display() ); println!("Generating tool '{}' with AI...\n", name); let status = Command::new(localcode.unwrap()) .arg("--print") .arg(&prompt) .status() .context("failed to run localcode")?; if !status.success() { bail!("AI generation failed with status: {}", status); } if tool_file.exists() { println!("\nCreated tool: {}", tool_file.display()); println!("Run it with: f tools run {}", name); } } else { // Create template let desc = description.unwrap_or("TODO: Add description"); let content = format!( r#"// {desc} import {{ $ }} from "bun" const args = Bun.argv.slice(2) // TODO: Implement tool logic console.log("{name} tool running with args:", args) "#, desc = desc, name = name ); fs::write(&tool_file, content).context("failed to write tool file")?; println!("Created tool: {}", tool_file.display()); println!("\nEdit it with: f tools edit {}", name); println!("Run it with: f tools run {}", name); } Ok(()) } /// Edit a tool in the user's editor. fn edit_tool(name: &str) -> Result<()> { let tools_dir = get_tools_dir()?; let tool_file = tools_dir.join(format!("{}.ts", name)); if !tool_file.exists() { bail!( "Tool '{}' not found. Create it with: f tools new {}", name, name ); } let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); Command::new(&editor) .arg(&tool_file) .status() .with_context(|| format!("failed to open editor: {}", editor))?; Ok(()) } /// Remove a tool. fn remove_tool(name: &str) -> Result<()> { let tools_dir = get_tools_dir()?; let tool_file = tools_dir.join(format!("{}.ts", name)); if !tool_file.exists() { bail!("Tool '{}' not found", name); } fs::remove_file(&tool_file).context("failed to remove tool file")?; println!("Removed tool: {}", name); Ok(()) } ================================================ FILE: src/trace.rs ================================================ use std::{ collections::HashMap, fs::{self, File}, io::{BufRead, BufReader, Seek, SeekFrom}, path::{Path, PathBuf}, sync::mpsc, time::Duration, }; use anyhow::{Context, Result, bail}; use base64::{Engine, engine::general_purpose}; use notify::{RecursiveMode, Watcher}; use crate::cli::TraceOpts; const META_DIR_SUFFIX: &str = ".flow/tty-meta"; const TTY_LOG_DIR_SUFFIX: &str = ".flow/tmux-logs"; pub fn run(opts: TraceOpts) -> Result<()> { if opts.last_command { return print_last_command(); } stream_operations() } fn stream_operations() -> Result<()> { let meta_dir = meta_dir(); if !meta_dir.exists() { bail!( "no meta dir at {}; enable trace_terminal_io and open a new terminal", meta_dir.display() ); } let mut positions = HashMap::new(); bootstrap_existing(&meta_dir, &mut positions)?; let (tx, rx) = mpsc::channel(); let mut watcher = notify::recommended_watcher(move |res| { let _ = tx.send(res); }) .context("failed to start watcher on tty meta dir")?; watcher .watch(&meta_dir, RecursiveMode::NonRecursive) .with_context(|| format!("failed to watch {}", meta_dir.display()))?; println!("# streaming command events (Ctrl+C to stop)"); loop { match rx.recv_timeout(Duration::from_millis(500)) { Ok(Ok(event)) => { for path in event.paths { if path.extension().and_then(|s| s.to_str()) != Some("log") { continue; } let _ = print_new_lines(&path, &mut positions); } } Ok(Err(err)) => { eprintln!("watch error: {err}"); } Err(mpsc::RecvTimeoutError::Timeout) => { // poll for new files let _ = bootstrap_existing(&meta_dir, &mut positions); } Err(mpsc::RecvTimeoutError::Disconnected) => break, } } Ok(()) } fn bootstrap_existing(meta_dir: &Path, positions: &mut HashMap<PathBuf, u64>) -> Result<()> { for entry in fs::read_dir(meta_dir).with_context(|| format!("failed to read {}", meta_dir.display()))? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("log") { continue; } if !positions.contains_key(&path) { positions.insert(path.clone(), 0); print_new_lines(&path, positions)?; } } Ok(()) } fn print_new_lines(path: &Path, positions: &mut HashMap<PathBuf, u64>) -> Result<()> { let mut file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; let pos = positions.entry(path.to_path_buf()).or_insert(0); file.seek(SeekFrom::Start(*pos)) .with_context(|| format!("failed to seek {}", path.display()))?; let mut reader = BufReader::new(file); let mut buf = String::new(); while reader.read_line(&mut buf)? != 0 { *pos += buf.len() as u64; if let Some(evt) = parse_meta_line(buf.trim_end()) { println!("{}", format_event(evt, path)); } buf.clear(); } Ok(()) } fn print_last_command() -> Result<()> { let meta_dir = meta_dir(); let tty_dir = tty_dir(); if !meta_dir.exists() { bail!( "no meta data found at {}; enable trace_terminal_io and run commands inside tmux", meta_dir.display() ); } if !tty_dir.exists() { bail!( "no tmux logs at {}; ensure shells run inside tmux", tty_dir.display() ); } let (last_evt, start_map) = latest_event(&meta_dir)?; let Some(evt) = last_evt else { bail!("no commands recorded yet"); }; let cmd = start_map.get(&evt.id).cloned(); let output = extract_command_output(&evt.id, &tty_dir) .with_context(|| format!("failed to find output for command {}", evt.id))?; if let Some(start) = cmd { println!( "command: {}", start.cmd.unwrap_or_else(|| "<unknown>".to_string()) ); if let Some(cwd) = start.cwd { println!("cwd: {cwd}"); } } else { println!("command: <unknown>"); } if let Some(status) = evt.status { println!("status: {status}"); } println!("--- output ---"); print!("{output}"); Ok(()) } fn extract_command_output(id: &str, tty_dir: &Path) -> Result<String> { let start_marker = format!("flow-cmd-start;{id}"); let end_marker = format!("flow-cmd-end;{id}"); for entry in fs::read_dir(tty_dir).with_context(|| format!("failed to read {}", tty_dir.display()))? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("log") { continue; } let content = fs::read_to_string(&path) .with_context(|| format!("failed to read tty log {}", path.display()))?; if let Some(start_pos) = content.find(&start_marker) { let after_start = content[start_pos..] .find('\x07') .map(|idx| start_pos + idx + 1) .unwrap_or(start_pos); if let Some(end_pos) = content[after_start..].find(&end_marker) { let end_idx = after_start + end_pos; let slice = &content[after_start..end_idx]; return Ok(slice.trim_matches(|c| c == '\n' || c == '\r').to_string()); } } } bail!("command id {id} not found in tty logs; ensure command ran inside tmux") } #[derive(Clone)] struct MetaEvent { ts: String, id: String, kind: MetaKind, cmd: Option<String>, cwd: Option<String>, status: Option<i32>, } #[derive(Clone)] enum MetaKind { Start, End, } fn parse_meta_line(line: &str) -> Option<MetaEvent> { let mut parts = line.split_whitespace(); let kind = parts.next()?; let ts = parts.next()?.to_string(); match kind { "start" => { let id = parts.next()?.to_string(); let cwd_b64 = parts.next().unwrap_or(""); let cmd_b64 = parts.next().unwrap_or(""); Some(MetaEvent { ts, id, kind: MetaKind::Start, cwd: decode_b64(cwd_b64), cmd: decode_b64(cmd_b64), status: None, }) } "end" => { let id = parts.next()?.to_string(); let status = parts.next().and_then(|s| s.parse::<i32>().ok()); Some(MetaEvent { ts, id, kind: MetaKind::End, cmd: None, cwd: None, status, }) } _ => None, } } fn format_event(evt: MetaEvent, path: &Path) -> String { match evt.kind { MetaKind::Start => format!( "[{} {}] start {} (cwd: {})", path.file_stem().and_then(|s| s.to_str()).unwrap_or("pane"), evt.ts, evt.cmd.unwrap_or_else(|| "<unknown>".to_string()), evt.cwd.unwrap_or_else(|| "?".to_string()) ), MetaKind::End => format!( "[{} {}] end status={}", path.file_stem().and_then(|s| s.to_str()).unwrap_or("pane"), evt.ts, evt.status .map(|s| s.to_string()) .unwrap_or_else(|| "?".to_string()) ), } } fn latest_event(meta_dir: &Path) -> Result<(Option<MetaEvent>, HashMap<String, MetaEvent>)> { let mut last: Option<MetaEvent> = None; let mut starts: HashMap<String, MetaEvent> = HashMap::new(); for entry in fs::read_dir(meta_dir).with_context(|| format!("failed to read {}", meta_dir.display()))? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("log") { continue; } let file = File::open(&path).with_context(|| format!("failed to open {}", path.display()))?; let reader = BufReader::new(file); for line in reader.lines() { let line = match line { Ok(l) => l, Err(_) => continue, }; if let Some(evt) = parse_meta_line(&line) { if matches!(evt.kind, MetaKind::Start) { starts.insert(evt.id.clone(), evt.clone()); } if last.as_ref().map_or(true, |prev| evt.ts > prev.ts) { last = Some(evt); } } } } Ok((last, starts)) } fn decode_b64(input: &str) -> Option<String> { general_purpose::STANDARD .decode(input.as_bytes()) .ok() .and_then(|bytes| String::from_utf8(bytes).ok()) } fn meta_dir() -> PathBuf { home_dir().join(META_DIR_SUFFIX) } fn tty_dir() -> PathBuf { home_dir().join(TTY_LOG_DIR_SUFFIX) } fn home_dir() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } ================================================ FILE: src/traces.rs ================================================ use std::env; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use groove::ObjectId; use groove::sql::{Database, RowValue}; use groove_rocksdb::RocksEnvironment; use crate::ai; use crate::cli::{TraceSessionOpts, TraceSource, TracesOpts}; use crate::jazz_state; const DEFAULT_LIMIT: usize = 40; const DETAIL_LIMIT: usize = 120; const FOLLOW_POLL_MS: u64 = 400; pub fn run(opts: TracesOpts) -> Result<()> { let flow_path = jazz_state::state_dir(); let flow_db = open_db_at(&flow_path)?; let ai_db = open_ai_db(&flow_path); let limit = if opts.limit == 0 { DEFAULT_LIMIT } else { opts.limit }; if opts.follow { follow_traces(&flow_db, ai_db.as_ref(), &opts, limit) } else { let mut items = fetch_all(&flow_db, ai_db.as_ref(), &opts, 0, limit, false)?; items.sort_by_key(|item| item.timestamp_ms); for item in items { println!("{}", format_item(&item)); } Ok(()) } } pub fn run_session(opts: TraceSessionOpts) -> Result<()> { let mut path = opts.path; if path.is_relative() { let cwd = env::current_dir().context("read current dir")?; path = cwd.join(path); } let path = path.canonicalize().unwrap_or(path); let history = ai::get_latest_session_history_for_path(&path)?; let Some(history) = history else { println!("No AI sessions found for {}", path.display()); return Ok(()); }; let id_short = &history.session_id[..8.min(history.session_id.len())]; println!("# session {}:{}", history.provider, id_short); println!("# path {}", path.display()); if let Some(ts) = history.started_at.as_deref() { println!("# started_at {ts}"); } if let Some(ts) = history.last_message_at.as_deref() { println!("# last_message_at {ts}"); } for message in history.messages { println!(); println!("[{}]", message.role); println!("{}", message.content); } Ok(()) } fn follow_traces( flow_db: &Database, ai_db: Option<&Database>, opts: &TracesOpts, limit: usize, ) -> Result<()> { let mut since = 0u64; let mut initial = fetch_all(flow_db, ai_db, opts, 0, limit, true)?; initial.sort_by_key(|item| item.timestamp_ms); for item in &initial { println!("{}", format_item(item)); since = since.max(item.timestamp_ms); } loop { std::thread::sleep(Duration::from_millis(FOLLOW_POLL_MS)); let mut items = fetch_all(flow_db, ai_db, opts, since, limit, true)?; items.sort_by_key(|item| item.timestamp_ms); for item in &items { if item.timestamp_ms > since { println!("{}", format_item(item)); since = since.max(item.timestamp_ms); } } } } fn fetch_all( flow_db: &Database, ai_db: Option<&Database>, opts: &TracesOpts, since: u64, limit: usize, ascending: bool, ) -> Result<Vec<TraceItem>> { let mut items = Vec::new(); match opts.source { TraceSource::All => { items.extend(fetch_task_runs( flow_db, opts.project.as_deref(), since, limit, ascending, )?); let agent_db = ai_db.unwrap_or(flow_db); items.extend(fetch_agent_events( agent_db, opts.project.as_deref(), since, limit, ascending, )?); } TraceSource::Tasks => { items.extend(fetch_task_runs( flow_db, opts.project.as_deref(), since, limit, ascending, )?); } TraceSource::Ai => { let agent_db = ai_db.unwrap_or(flow_db); items.extend(fetch_agent_events( agent_db, opts.project.as_deref(), since, limit, ascending, )?); } } Ok(items) } fn fetch_task_runs( db: &Database, project_filter: Option<&str>, since: u64, limit: usize, ascending: bool, ) -> Result<Vec<TraceItem>> { let order = if ascending { "ASC" } else { "DESC" }; let sql = format!( "SELECT task, command, success, status, duration_ms, timestamp_ms, output, project_root \ FROM flow_task_runs WHERE timestamp_ms > {} ORDER BY timestamp_ms {} LIMIT {}", since, order, limit ); let rows = db.query(&sql).unwrap_or_default(); let mut items = Vec::new(); for (_, row) in rows { let task = match row.get_by_name("task") { Some(RowValue::String(s)) => s.to_string(), _ => continue, }; let project = match row.get_by_name("project_root") { Some(RowValue::String(s)) => s.to_string(), _ => String::new(), }; if let Some(filter) = project_filter { if !project.contains(filter) { continue; } } let success = matches!(row.get_by_name("success"), Some(RowValue::Bool(true))); let timestamp_ms = match row.get_by_name("timestamp_ms") { Some(RowValue::I64(ts)) => ts as u64, _ => 0, }; let duration_ms = match row.get_by_name("duration_ms") { Some(RowValue::I64(d)) => d as u64, _ => 0, }; let status = match row.get_by_name("status") { Some(RowValue::I64(s)) => Some(s), _ => None, }; let output = match row.get_by_name("output") { Some(RowValue::String(s)) => s.to_string(), _ => String::new(), }; let command = match row.get_by_name("command") { Some(RowValue::String(s)) => s.to_string(), _ => String::new(), }; let kind = if success { "task_ok" } else { "task_fail" }; let detail = if success { format!("{}ms", duration_ms) } else { let status_str = status.map(|s| format!("exit {}", s)).unwrap_or_default(); let last_line = output.lines().last().unwrap_or("").trim(); if last_line.is_empty() { status_str } else { format!("{} | {}", status_str, last_line) } }; items.push(TraceItem { timestamp_ms, source: "task", kind: kind.to_string(), summary: task, detail, project, extra: command, }); } Ok(items) } fn fetch_agent_events( db: &Database, project_filter: Option<&str>, since: u64, limit: usize, ascending: bool, ) -> Result<Vec<TraceItem>> { let order = if ascending { "ASC" } else { "DESC" }; let sql = format!( "SELECT event_kind, summary, detail, timestamp_ms, project_root \ FROM ai_agent_events WHERE timestamp_ms > {} ORDER BY timestamp_ms {} LIMIT {}", since, order, limit ); let rows = db.query(&sql).unwrap_or_default(); let mut items = Vec::new(); for (_, row) in rows { let kind = match row.get_by_name("event_kind") { Some(RowValue::String(s)) => s.to_string(), _ => continue, }; let summary = match row.get_by_name("summary") { Some(RowValue::String(s)) => s.to_string(), _ => String::new(), }; let detail = match row.get_by_name("detail") { Some(RowValue::String(s)) => s.to_string(), _ => String::new(), }; let timestamp_ms = match row.get_by_name("timestamp_ms") { Some(RowValue::I64(ts)) => ts as u64, _ => 0, }; let project = match row.get_by_name("project_root") { Some(RowValue::String(s)) => s.to_string(), _ => String::new(), }; if let Some(filter) = project_filter { if !project.contains(filter) { continue; } } items.push(TraceItem { timestamp_ms, source: "ai", kind, summary, detail, project, extra: String::new(), }); } Ok(items) } #[derive(Clone)] struct TraceItem { timestamp_ms: u64, source: &'static str, kind: String, summary: String, detail: String, project: String, extra: String, } fn format_item(item: &TraceItem) -> String { let time = format_timestamp(item.timestamp_ms); let project = project_label(&item.project); let detail = truncate(&item.detail, DETAIL_LIMIT); let summary = if item.summary.is_empty() { item.kind.clone() } else { item.summary.clone() }; let extra = if item.extra.is_empty() { String::new() } else { format!(" | {}", truncate(&item.extra, 60)) }; format!( "{} {:>4} {:<10} {} ({}) | {}{}", time, item.source, item.kind, summary, project, detail, extra ) } fn format_timestamp(timestamp_ms: u64) -> String { let system_time = UNIX_EPOCH + Duration::from_millis(timestamp_ms); let dt: chrono::DateTime<chrono::Local> = system_time.into(); dt.format("%H:%M:%S%.3f").to_string() } fn project_label(project: &str) -> String { project.rsplit('/').next().unwrap_or(project).to_string() } fn truncate(value: &str, limit: usize) -> String { if value.len() <= limit { return value.to_string(); } let mut end = limit; while end > 0 && !value.is_char_boundary(end) { end -= 1; } format!("{}…", &value[..end]) } fn open_db_at(path: &Path) -> Result<Database> { use groove::Environment; if !path.exists() { bail!("jazz2 state not found at {}", path.display()); } let env: Arc<dyn Environment> = Arc::new(RocksEnvironment::open(&path).context("open rocksdb")?); let catalog_id = load_catalog_id(&path).context("load catalog id")?; let db = futures::executor::block_on(Database::from_env(env, catalog_id)) .context("load jazz2 catalog")?; Ok(db) } fn open_ai_db(flow_path: &Path) -> Option<Database> { let path = if let Ok(path) = env::var("AI_JAZZ2_PATH") { PathBuf::from(path) } else { flow_path.join("ai") }; if path == flow_path || !path.exists() { return None; } open_db_at(&path).ok() } fn load_catalog_id(base: &Path) -> Result<ObjectId> { let path = base.join("catalog.id"); let contents = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let trimmed = contents.trim(); let id = trimmed .parse::<ObjectId>() .with_context(|| format!("parse catalog id {}", trimmed))?; Ok(id) } ================================================ FILE: src/traces_stub.rs ================================================ use anyhow::{Result, bail}; use crate::base_tool; use crate::cli::{TraceSessionOpts, TraceSource, TracesOpts}; pub fn run(opts: TracesOpts) -> Result<()> { let Some(bin) = base_tool::resolve_bin() else { bail!( "traces require the base tool (FLOW_BASE_BIN).\n\ Install it, then retry.\n\ (Expected `base` or `db` on PATH, or set FLOW_BASE_BIN=/path/to/base)" ); }; let mut args: Vec<String> = vec![ "trace".to_string(), "--limit".to_string(), opts.limit.to_string(), ]; if opts.follow { args.push("--follow".to_string()); } if let Some(project) = opts .project .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { args.push("--project".to_string()); args.push(project.to_string()); } args.push("--source".to_string()); args.push( match opts.source { TraceSource::All => "all", TraceSource::Tasks => "tasks", TraceSource::Ai => "ai", } .to_string(), ); base_tool::run_inherit_stdio(&bin, &args) } pub fn run_session(_opts: TraceSessionOpts) -> Result<()> { let Some(bin) = base_tool::resolve_bin() else { bail!( "trace session requires the base tool (FLOW_BASE_BIN).\n\ Install it, then retry.\n\ (Expected `base` or `db` on PATH, or set FLOW_BASE_BIN=/path/to/base)" ); }; // Keep behavior compatible with Flow's old implementation: always show full session history. let mut args: Vec<String> = vec!["session".to_string()]; args.push(_opts.path.display().to_string()); base_tool::run_inherit_stdio(&bin, &args) } pub fn trace_source_from_str(_value: &str) -> TraceSource { TraceSource::Tasks } ================================================ FILE: src/undo.rs ================================================ //! Undo system for flow actions. //! //! Tracks undoable actions (commit, push, etc.) and provides undo functionality. use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use tracing::{debug, info}; use crate::cli::{UndoAction, UndoCommand}; /// Run the undo command. pub fn run(cmd: UndoCommand) -> Result<()> { let cwd = std::env::current_dir()?; // Find repository root let repo_root = find_repo_root(&cwd)?; match cmd.action { Some(UndoAction::Show) => { show_last(&repo_root)?; } Some(UndoAction::List { limit }) => { list_actions(&repo_root, limit)?; } None => { // Default action: undo the last action let opts = UndoOpts { dry_run: cmd.dry_run, force: cmd.force, }; match undo_last(&repo_root, &opts) { Ok(result) => { if !cmd.dry_run { if result.force_pushed { println!("\nAction undone. Remote has been updated."); } else { println!("\nAction undone."); } } } Err(e) => { bail!("{}", e); } } } } Ok(()) } /// Find the git repository root from a path. fn find_repo_root(start: &Path) -> Result<PathBuf> { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .current_dir(start) .output() .context("failed to find git repository")?; if !output.status.success() { bail!("Not in a git repository"); } let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); Ok(PathBuf::from(path)) } /// Action types that can be undone. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ActionType { /// Git commit (can be undone with reset) Commit, /// Git push (can be undone with force push) Push, /// Commit + push together CommitPush, } impl std::fmt::Display for ActionType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ActionType::Commit => write!(f, "commit"), ActionType::Push => write!(f, "push"), ActionType::CommitPush => write!(f, "commit+push"), } } } /// Record of an undoable action. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UndoRecord { /// Timestamp when action was performed (ISO 8601) pub timestamp: String, /// Type of action pub action: ActionType, /// Git commit SHA before the action (for reverting) pub before_sha: String, /// Git commit SHA after the action pub after_sha: String, /// Branch name pub branch: String, /// Whether the action included a push pub pushed: bool, /// Remote name (if pushed) pub remote: Option<String>, /// Commit message (for display) pub message: Option<String>, } /// Get the undo log path for a repository. fn undo_log_path(repo_root: &Path) -> PathBuf { repo_root .join(".ai") .join("internal") .join("undo-log.jsonl") } /// Record an undoable action. pub fn record_action( repo_root: &Path, action: ActionType, before_sha: &str, after_sha: &str, branch: &str, pushed: bool, remote: Option<&str>, message: Option<&str>, ) -> Result<()> { let log_path = undo_log_path(repo_root); // Ensure parent directory exists if let Some(parent) = log_path.parent() { fs::create_dir_all(parent)?; } let record = UndoRecord { timestamp: chrono::Utc::now().to_rfc3339(), action, before_sha: before_sha.to_string(), after_sha: after_sha.to_string(), branch: branch.to_string(), pushed, remote: remote.map(|s| s.to_string()), message: message.map(|s| s.to_string()), }; let line = serde_json::to_string(&record)?; // Append to log file let mut file = fs::OpenOptions::new() .create(true) .append(true) .open(&log_path)?; use std::io::Write; writeln!(file, "{}", line)?; debug!( action = %record.action, before = %record.before_sha, after = %record.after_sha, "recorded undo action" ); Ok(()) } /// Get the last undoable action for the current repository. pub fn get_last_action(repo_root: &Path) -> Result<Option<UndoRecord>> { let log_path = undo_log_path(repo_root); if !log_path.exists() { return Ok(None); } let content = fs::read_to_string(&log_path)?; let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { return Ok(None); } // Get the last line let last_line = lines.last().unwrap(); let record: UndoRecord = serde_json::from_str(last_line).context("failed to parse last undo record")?; Ok(Some(record)) } /// Remove the last action from the undo log. fn remove_last_action(repo_root: &Path) -> Result<()> { let log_path = undo_log_path(repo_root); if !log_path.exists() { return Ok(()); } let content = fs::read_to_string(&log_path)?; let mut lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { return Ok(()); } // Remove last line lines.pop(); // Rewrite file let new_content = if lines.is_empty() { String::new() } else { lines.join("\n") + "\n" }; fs::write(&log_path, new_content)?; Ok(()) } /// Options for undo operation. #[derive(Debug, Default)] pub struct UndoOpts { /// Dry run - show what would be done without doing it pub dry_run: bool, /// Force undo even if it requires force push pub force: bool, } /// Result of an undo operation. #[derive(Debug)] pub struct UndoResult { pub action_type: ActionType, pub before_sha: String, pub after_sha: String, pub force_pushed: bool, } /// Undo the last action. pub fn undo_last(repo_root: &Path, opts: &UndoOpts) -> Result<UndoResult> { let record = get_last_action(repo_root)?.ok_or_else(|| anyhow::anyhow!("No actions to undo"))?; // Check if we're on the same branch let current_branch = git_capture(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])?; if current_branch.trim() != record.branch { bail!( "Currently on branch '{}', but last action was on '{}'. Switch branches first.", current_branch.trim(), record.branch ); } // Check if HEAD matches the after_sha let current_sha = git_capture(repo_root, &["rev-parse", "HEAD"])?; if !current_sha .trim() .starts_with(&record.after_sha[..7.min(record.after_sha.len())]) { // Try short comparison let current_short = ¤t_sha.trim()[..7.min(current_sha.len())]; let record_short = &record.after_sha[..7.min(record.after_sha.len())]; if current_short != record_short { bail!( "HEAD ({}) doesn't match the recorded action ({}). \ The repository state has changed since the action was recorded.", current_short, record_short ); } } if opts.dry_run { println!( "Would undo: {} ({})", record.action, short_sha(&record.after_sha) ); println!(" Reset to: {}", short_sha(&record.before_sha)); if record.pushed { println!(" Would force push to remote"); } return Ok(UndoResult { action_type: record.action.clone(), before_sha: record.before_sha.clone(), after_sha: record.after_sha.clone(), force_pushed: false, }); } // Perform the undo based on action type match &record.action { ActionType::Commit => { undo_commit(repo_root, &record)?; } ActionType::Push => { if !opts.force { bail!("Undoing a push requires --force flag (this will force push to remote)"); } undo_push(repo_root, &record)?; } ActionType::CommitPush => { if record.pushed && !opts.force { bail!("This action was pushed to remote. Use --force to undo (will force push)"); } undo_commit_push(repo_root, &record, opts.force)?; } } // Remove from undo log after successful undo remove_last_action(repo_root)?; let force_pushed = record.pushed && (record.action == ActionType::Push || record.action == ActionType::CommitPush); Ok(UndoResult { action_type: record.action, before_sha: record.before_sha, after_sha: record.after_sha, force_pushed, }) } /// Undo a commit (reset --soft to keep changes staged). fn undo_commit(repo_root: &Path, record: &UndoRecord) -> Result<()> { info!(sha = %record.after_sha, "undoing commit"); // Use --soft to keep changes staged git_run(repo_root, &["reset", "--soft", &record.before_sha])?; println!("✓ Undid commit {}", short_sha(&record.after_sha)); println!(" Changes are still staged"); Ok(()) } /// Undo a push (force push the previous state). fn undo_push(repo_root: &Path, record: &UndoRecord) -> Result<()> { let remote = record.remote.as_deref().unwrap_or("origin"); info!( sha = %record.after_sha, remote = %remote, branch = %record.branch, "undoing push with force push" ); // Force push the before_sha to the branch git_run( repo_root, &[ "push", "--force", remote, &format!("{}:{}", record.before_sha, record.branch), ], )?; println!( "✓ Force pushed {} to {}/{}", short_sha(&record.before_sha), remote, record.branch ); Ok(()) } /// Undo a commit+push operation. fn undo_commit_push(repo_root: &Path, record: &UndoRecord, force: bool) -> Result<()> { info!(sha = %record.after_sha, pushed = record.pushed, "undoing commit+push"); // First, reset the local commit git_run(repo_root, &["reset", "--soft", &record.before_sha])?; println!("✓ Undid commit {}", short_sha(&record.after_sha)); // If it was pushed, force push to revert remote if record.pushed && force { let remote = record.remote.as_deref().unwrap_or("origin"); git_run(repo_root, &["push", "--force", remote, &record.branch])?; println!("✓ Force pushed to {}/{}", remote, record.branch); } println!(" Changes are still staged"); Ok(()) } /// Show the last undoable action without undoing it. pub fn show_last(repo_root: &Path) -> Result<()> { match get_last_action(repo_root)? { Some(record) => { println!("Last undoable action:"); println!(" Type: {}", record.action); println!(" Time: {}", record.timestamp); println!(" Branch: {}", record.branch); println!(" Before: {}", short_sha(&record.before_sha)); println!(" After: {}", short_sha(&record.after_sha)); if record.pushed { println!( " Pushed: yes (to {})", record.remote.as_deref().unwrap_or("origin") ); } if let Some(msg) = &record.message { let short_msg = if msg.len() > 60 { format!("{}...", &msg[..57]) } else { msg.clone() }; println!(" Message: {}", short_msg); } } None => { println!("No undoable actions recorded for this repository."); } } Ok(()) } /// List recent undoable actions. pub fn list_actions(repo_root: &Path, limit: usize) -> Result<()> { let log_path = undo_log_path(repo_root); if !log_path.exists() { println!("No undo history for this repository."); return Ok(()); } let content = fs::read_to_string(&log_path)?; let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { println!("No undo history for this repository."); return Ok(()); } println!("Recent actions (newest first):"); println!(); let start = if lines.len() > limit { lines.len() - limit } else { 0 }; for (i, line) in lines[start..].iter().rev().enumerate() { if let Ok(record) = serde_json::from_str::<UndoRecord>(line) { let pushed_indicator = if record.pushed { " [pushed]" } else { "" }; let msg_short = record .message .as_ref() .map(|m| { if m.len() > 40 { format!("{:.40}...", m) } else { m.clone() } }) .unwrap_or_default(); if i == 0 { println!( " → {} {} {}{} {}", short_sha(&record.after_sha), record.action, record.branch, pushed_indicator, msg_short ); } else { println!( " {} {} {}{} {}", short_sha(&record.after_sha), record.action, record.branch, pushed_indicator, msg_short ); } } } println!(); println!("Use 'f undo' to undo the most recent action (→)"); Ok(()) } // Helper functions fn short_sha(sha: &str) -> &str { &sha[..7.min(sha.len())] } fn git_capture(repo_root: &Path, args: &[&str]) -> Result<String> { let output = Command::new("git") .args(args) .current_dir(repo_root) .output() .context("failed to run git command")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git {} failed: {}", args.join(" "), stderr); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } fn git_run(repo_root: &Path, args: &[&str]) -> Result<()> { let status = Command::new("git") .args(args) .current_dir(repo_root) .status() .context("failed to run git command")?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_action_type_display() { assert_eq!(format!("{}", ActionType::Commit), "commit"); assert_eq!(format!("{}", ActionType::Push), "push"); assert_eq!(format!("{}", ActionType::CommitPush), "commit+push"); } #[test] fn test_short_sha() { assert_eq!(short_sha("abc1234567890"), "abc1234"); assert_eq!(short_sha("abc"), "abc"); } } ================================================ FILE: src/upgrade.rs ================================================ //! Self-upgrade functionality for flow. //! //! Similar to Deno's upgrade system: //! - Fetches latest version from GitHub releases //! - Downloads and replaces the current binary //! - Background version checking with caching use std::env; use std::fs::{self, File}; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use serde::Deserialize; use sha2::{Digest, Sha256}; use crate::cli::UpgradeOpts; const UPGRADE_CHECK_INTERVAL_HOURS: u64 = 24; fn env_truthy(key: &str) -> bool { match env::var(key) .ok() .map(|v| v.trim().to_ascii_lowercase()) .as_deref() { Some("1") | Some("true") | Some("yes") | Some("y") => true, _ => false, } } fn upgrade_repo() -> Result<(String, String)> { if let Ok(value) = env::var("FLOW_UPGRADE_REPO") { if let Some((owner, repo)) = value.trim().split_once('/') { if !owner.trim().is_empty() && !repo.trim().is_empty() { return Ok((owner.trim().to_string(), repo.trim().to_string())); } } } if let (Ok(owner), Ok(repo)) = (env::var("FLOW_GITHUB_OWNER"), env::var("FLOW_GITHUB_REPO")) { let owner = owner.trim(); let repo = repo.trim(); if !owner.is_empty() && !repo.is_empty() { return Ok((owner.to_string(), repo.to_string())); } } if let Some((owner, repo)) = parse_github_owner_repo(env!("CARGO_PKG_REPOSITORY")) { return Ok((owner, repo)); } bail!( "upgrade source repo not configured.\nSet FLOW_UPGRADE_REPO=owner/repo (recommended) or FLOW_GITHUB_OWNER/FLOW_GITHUB_REPO." ); } #[derive(Debug, Deserialize)] struct GitHubRelease { tag_name: String, assets: Vec<GitHubAsset>, html_url: String, } #[derive(Debug, Deserialize)] struct GitHubAsset { name: String, browser_download_url: String, } /// Version check cache stored in ~/.cache/flow/upgrade_check.txt #[derive(Debug)] struct VersionCache { last_checked: u64, latest_version: String, current_version: String, } impl VersionCache { fn cache_path() -> PathBuf { dirs::cache_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("flow") .join("upgrade_check.txt") } fn load() -> Option<Self> { let path = Self::cache_path(); let content = fs::read_to_string(&path).ok()?; let parts: Vec<&str> = content.trim().split('!').collect(); if parts.len() >= 3 { Some(Self { last_checked: parts[0].parse().ok()?, latest_version: parts[1].to_string(), current_version: parts[2].to_string(), }) } else { None } } fn save(&self) -> Result<()> { let path = Self::cache_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let content = format!( "{}!{}!{}", self.last_checked, self.latest_version, self.current_version ); fs::write(&path, content)?; Ok(()) } fn now_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } fn should_check(&self) -> bool { let now = Self::now_timestamp(); let elapsed_hours = (now.saturating_sub(self.last_checked)) / 3600; elapsed_hours >= UPGRADE_CHECK_INTERVAL_HOURS } } /// Get current version from Cargo.toml embedded at compile time. pub fn current_version() -> &'static str { env!("CARGO_PKG_VERSION") } /// Detect the current platform (os, arch). fn detect_release_target() -> Result<&'static str> { if cfg!(target_os = "macos") { if cfg!(target_arch = "x86_64") { return Ok("x86_64-apple-darwin"); } if cfg!(target_arch = "aarch64") { return Ok("aarch64-apple-darwin"); } bail!("Unsupported macOS architecture"); } if cfg!(target_os = "linux") { if cfg!(target_arch = "x86_64") { return Ok("x86_64-unknown-linux-gnu"); } if cfg!(target_arch = "aarch64") { return Ok("aarch64-unknown-linux-gnu"); } bail!("Unsupported Linux architecture"); } bail!("Unsupported operating system for self-upgrade (only macOS/Linux supported)"); } fn detect_legacy_platform() -> Result<(&'static str, &'static str)> { let os = if cfg!(target_os = "macos") { "darwin" } else if cfg!(target_os = "linux") { "linux" } else { bail!("Unsupported operating system for self-upgrade (only macOS/Linux supported)"); }; let arch = if cfg!(target_arch = "aarch64") { "arm64" } else if cfg!(target_arch = "x86_64") { "amd64" } else { bail!("Unsupported architecture"); }; Ok((os, arch)) } /// Fetch the latest release info from GitHub. fn fetch_latest_release(client: &Client) -> Result<GitHubRelease> { let (owner, repo) = upgrade_repo()?; let url = format!( "https://api.github.com/repos/{}/{}/releases/latest", owner, repo ); let mut request = client .get(&url) .header("User-Agent", format!("flow/{}", current_version())) .header("Accept", "application/vnd.github.v3+json") .timeout(Duration::from_secs(30)); if let Some(token) = github_token() { request = request.bearer_auth(token); } let response = request .send() .context("Failed to fetch release info from GitHub")?; if !response.status().is_success() { bail!( "GitHub API returned status {}: {}", response.status(), response.text().unwrap_or_default() ); } response .json::<GitHubRelease>() .context("Failed to parse GitHub release response") } /// Fetch a release by tag (e.g. "v0.1.0") from GitHub. fn fetch_release_by_tag(client: &Client, tag: &str) -> Result<GitHubRelease> { let (owner, repo) = upgrade_repo()?; let url = format!( "https://api.github.com/repos/{}/{}/releases/tags/{}", owner, repo, tag ); let mut request = client .get(&url) .header("User-Agent", format!("flow/{}", current_version())) .header("Accept", "application/vnd.github.v3+json") .timeout(Duration::from_secs(30)); if let Some(token) = github_token() { request = request.bearer_auth(token); } let response = request .send() .context("Failed to fetch release info from GitHub")?; if response.status() == reqwest::StatusCode::NOT_FOUND { bail!( "Release tag '{}' not found in {}/{}.\n\ If you meant canary: wait for the canary workflow to publish it (GitHub release tag: canary).", tag, owner, repo ); } if !response.status().is_success() { bail!( "GitHub API returned status {}: {}", response.status(), response.text().unwrap_or_default() ); } response .json::<GitHubRelease>() .context("Failed to parse GitHub release response") } /// Parse version string, stripping 'v' prefix if present. fn parse_version(version: &str) -> &str { version.strip_prefix('v').unwrap_or(version) } /// Compare two semver-like versions. Returns true if `latest` is newer than `current`. fn is_newer_version(current: &str, latest: &str) -> bool { let current = parse_version(current); let latest = parse_version(latest); let parse_parts = |v: &str| -> Vec<u32> { v.split(|c: char| c == '.' || c == '-') .filter_map(|s| s.parse().ok()) .collect() }; let current_parts = parse_parts(current); let latest_parts = parse_parts(latest); for (c, l) in current_parts.iter().zip(latest_parts.iter()) { if l > c { return true; } if l < c { return false; } } latest_parts.len() > current_parts.len() } /// Download a file with progress indication. fn download_with_progress(client: &Client, url: &str, dest: &Path) -> Result<()> { let response = client .get(url) .header("User-Agent", format!("flow/{}", current_version())) .timeout(Duration::from_secs(300)) .send() .context("Failed to start download")?; if !response.status().is_success() { bail!("Download failed with status {}", response.status()); } let total_size = response.content_length(); let mut file = File::create(dest).context("Failed to create temp file")?; let bytes = response.bytes().context("Failed to read response")?; if let Some(total) = total_size { println!("Downloading {} bytes...", total); } file.write_all(&bytes)?; Ok(()) } fn github_token() -> Option<String> { for key in ["GITHUB_TOKEN", "GH_TOKEN", "FLOW_GITHUB_TOKEN"] { if let Ok(value) = env::var(key) { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } None } fn parse_github_owner_repo(url: &str) -> Option<(String, String)> { let trimmed = url.trim().trim_end_matches('/'); if trimmed.is_empty() { return None; } let rest = if let Some(rest) = trimmed.strip_prefix("git@github.com:") { rest } else if let Some(rest) = trimmed.strip_prefix("https://github.com/") { rest } else { return None; }; let rest = rest.trim_end_matches(".git"); let mut parts = rest.split('/'); let owner = parts.next()?.trim(); let repo = parts.next()?.trim(); if owner.is_empty() || repo.is_empty() { return None; } Some((owner.to_string(), repo.to_string())) } fn normalize_tag(input: &str) -> String { let trimmed = input.trim(); if trimmed.starts_with('v') { trimmed.to_string() } else { format!("v{}", trimmed) } } fn parse_sha256_from_checksums(checksums: &str, filename: &str) -> Option<String> { for line in checksums.lines() { let mut parts = line.split_whitespace(); let hash = parts.next()?; let file = parts.next()?; if file.trim() == filename { return Some(hash.trim().to_string()); } } None } fn sha256_file(path: &Path) -> Result<String> { let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; let mut hasher = Sha256::new(); hasher.update(&bytes); Ok(hex::encode(hasher.finalize())) } /// Extract tarball and find the binary. fn extract_binary(tarball: &Path, binary_name: &str) -> Result<PathBuf> { let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; let temp_path = temp_dir.path(); // Extract tarball let status = Command::new("tar") .args([ "-xzf", tarball.to_str().unwrap(), "-C", temp_path.to_str().unwrap(), ]) .status() .context("Failed to run tar")?; if !status.success() { bail!("Failed to extract tarball"); } // Find the binary (might be in a subdirectory) let find_binary = |dir: &Path| -> Option<PathBuf> { if dir.join(binary_name).exists() { return Some(dir.join(binary_name)); } // Check one level deep if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let bin_path = path.join(binary_name); if bin_path.exists() { return Some(bin_path); } } } } None }; let binary_path = find_binary(temp_path) .ok_or_else(|| anyhow::anyhow!("Binary '{}' not found in tarball", binary_name))?; // Copy to a persistent temp location let dest = env::temp_dir().join(format!("flow_upgrade_{}", binary_name)); fs::copy(&binary_path, &dest).context("Failed to copy binary")?; Ok(dest) } /// Validate the new binary by running --version. fn validate_binary(path: &Path) -> Result<String> { let output = Command::new(path) .arg("--version") .output() .context("Failed to validate new binary")?; if !output.status.success() { bail!("New binary validation failed"); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// Replace the current executable with the new one. fn replace_executable(new_exe: &Path, current_exe: &Path) -> Result<()> { #[cfg(unix)] { // On Unix, we can delete the running executable and replace it fs::remove_file(current_exe).context("Failed to remove current executable")?; fs::copy(new_exe, current_exe).context("Failed to copy new executable")?; // Set executable permissions use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(current_exe)?.permissions(); perms.set_mode(0o755); fs::set_permissions(current_exe, perms)?; } #[cfg(windows)] { // On Windows, rename the old executable first let old_exe = current_exe.with_extension("old.exe"); if old_exe.exists() { fs::remove_file(&old_exe).ok(); } fs::rename(current_exe, &old_exe).context("Failed to rename current executable")?; fs::copy(new_exe, current_exe).context("Failed to copy new executable")?; } Ok(()) } /// Get the path to the current executable. fn current_exe_path() -> Result<PathBuf> { env::current_exe().context("Failed to get current executable path") } /// Check write permissions for the executable path. fn check_write_permission(path: &Path) -> Result<()> { let parent = path.parent().unwrap_or(path); #[cfg(unix)] { use std::os::unix::fs::MetadataExt; let metadata = fs::metadata(path).or_else(|_| fs::metadata(parent))?; let uid = unsafe { libc::getuid() }; if metadata.uid() == 0 && uid != 0 { bail!( "You don't have write permission to {} because it's owned by root.\n\ Consider updating flow through your package manager if installed from it.\n\ Otherwise run `f upgrade` as root.", path.display() ); } } // Try to check if we can write if path.exists() { let metadata = fs::metadata(path)?; if metadata.permissions().readonly() { bail!("You do not have write permission to {}", path.display()); } } else if !parent.exists() || fs::metadata(parent)?.permissions().readonly() { bail!("You do not have write permission to {}", parent.display()); } Ok(()) } /// Run the upgrade command. pub fn run(opts: UpgradeOpts) -> Result<()> { let current = current_version(); let current_exe = current_exe_path()?; println!("Current version: {}", current); // Check write permissions early let output_path = opts .output .as_ref() .map(PathBuf::from) .unwrap_or_else(|| current_exe.clone()); check_write_permission(&output_path)?; let client = Client::builder() .timeout(Duration::from_secs(60)) .build() .context("Failed to create HTTP client")?; let (owner, repo) = upgrade_repo()?; println!("Upgrade source: {}/{}", owner, repo); // Fetch release println!("Checking for updates..."); let requested_version = opts .version .as_deref() .map(|v| v.trim()) .filter(|v| !v.is_empty()) .map(|v| v.to_string()); let (release, latest_display, skip_version_check) = if opts.canary { let release = fetch_release_by_tag(&client, "canary")?; (release, "canary".to_string(), true) } else if let Some(version) = requested_version.as_deref() { let tag = normalize_tag(version); let release = fetch_release_by_tag(&client, &tag)?; (release, parse_version(&tag).to_string(), true) // allow downgrades when version is explicit } else { let release = fetch_latest_release(&client)?; let latest = parse_version(&release.tag_name).to_string(); (release, latest, opts.stable) }; println!("Latest version: {}", latest_display); if opts.force { println!("Forcing upgrade..."); } // Check if upgrade is needed (stable channel only). if !opts.force && !skip_version_check { let latest = parse_version(&release.tag_name); if !is_newer_version(current, latest) { println!("Already on the latest version."); return Ok(()); } } // Detect platform and find the right asset. // Preferred format (new): `flow-<target>.tar.gz` (where <target> is the rust target triple). // Legacy format (old): `flow_<tag>_<os>_<arch>.tar.gz`. let target = detect_release_target()?; let asset_name = format!("flow-{}.tar.gz", target); let (legacy_os, legacy_arch) = detect_legacy_platform()?; let legacy_asset_name = format!( "flow_{}_{}_{}.tar.gz", release.tag_name, legacy_os, legacy_arch ); let tarball_asset = release .assets .iter() .find(|a| a.name == asset_name) .or_else(|| release.assets.iter().find(|a| a.name == legacy_asset_name)) .ok_or_else(|| { anyhow::anyhow!( "No release asset found for {}. Available: {:?}", target, release.assets.iter().map(|a| &a.name).collect::<Vec<_>>() ) })?; let checksums_asset = release.assets.iter().find(|a| a.name == "checksums.txt"); println!("Downloading {}...", tarball_asset.name); // Dry run mode if opts.dry_run { println!( "\n[dry-run] Would download: {}", tarball_asset.browser_download_url ); if let Some(asset) = checksums_asset { println!("[dry-run] Would download: {}", asset.browser_download_url); } println!("[dry-run] Would install to: {}", output_path.display()); return Ok(()); } // Download the release let temp_tarball = env::temp_dir().join("flow_upgrade.tar.gz"); download_with_progress(&client, &tarball_asset.browser_download_url, &temp_tarball)?; let insecure = env_truthy("FLOW_UPGRADE_INSECURE"); if let Some(asset) = checksums_asset { let temp_checksums = env::temp_dir().join("flow_upgrade_checksums.txt"); download_with_progress(&client, &asset.browser_download_url, &temp_checksums)?; let checksums = fs::read_to_string(&temp_checksums) .context("failed to read downloaded checksums.txt")?; if let Some(expected) = parse_sha256_from_checksums(&checksums, &tarball_asset.name) { let actual = sha256_file(&temp_tarball)?; if expected.to_lowercase() != actual.to_lowercase() { bail!( "checksum mismatch for {} (expected {}, got {})", tarball_asset.name, expected, actual ); } println!("Checksum verified."); } else if insecure { eprintln!( "Warning: checksums.txt does not contain {}; skipping checksum verification (FLOW_UPGRADE_INSECURE=1).", tarball_asset.name ); } else { bail!( "checksums.txt does not contain {}. Refusing to install.\n\ Set FLOW_UPGRADE_INSECURE=1 to bypass (not recommended).", tarball_asset.name ); } let _ = fs::remove_file(&temp_checksums); } else if insecure { eprintln!( "Warning: checksums.txt not found in release assets; skipping checksum verification (FLOW_UPGRADE_INSECURE=1)." ); } else { // Back-compat for older releases (e.g. v0.1.0) that don't ship checksums.txt. eprintln!( "Warning: checksums.txt not found in release assets; skipping checksum verification." ); } // Extract and find the binary println!("Extracting..."); let binary_name = if cfg!(windows) { "f.exe" } else { "f" }; let new_exe = extract_binary(&temp_tarball, binary_name)?; // Validate the new binary println!("Validating..."); let new_version = validate_binary(&new_exe)?; println!("New binary version: {}", new_version); // Replace the executable println!("Installing..."); replace_executable(&new_exe, &output_path)?; ensure_sibling_symlink(&output_path).ok(); // Cleanup fs::remove_file(&temp_tarball).ok(); fs::remove_file(&new_exe).ok(); // Update cache // Update cache (only meaningful for stable). if !opts.canary { let latest = parse_version(&release.tag_name); let cache = VersionCache { last_checked: VersionCache::now_timestamp(), latest_version: latest.to_string(), current_version: latest.to_string(), }; cache.save().ok(); } println!(); println!("Successfully upgraded to flow {}", latest_display); println!(); println!("Release notes: {}", release.html_url); Ok(()) } fn ensure_sibling_symlink(installed_path: &Path) -> Result<()> { #[cfg(not(unix))] { let _ = installed_path; return Ok(()); } #[cfg(unix)] { use std::os::unix::fs::symlink; let parent = installed_path.parent().context("missing parent dir")?; let Some(name) = installed_path.file_name().and_then(|n| n.to_str()) else { return Ok(()); }; // Support both `f upgrade ...` and `flow upgrade ...` by keeping a sibling symlink. let (link_name, target_name) = if name == "f" { ("flow", "f") } else if name == "flow" { ("f", "flow") } else { return Ok(()); }; let link_path = parent.join(link_name); if link_path.exists() && link_path.is_dir() { eprintln!( "Warning: cannot create {} symlink at {} (path is a directory)", link_name, link_path.display() ); return Ok(()); } let _ = fs::remove_file(&link_path); // Use relative target so moving the directory keeps the link valid. if symlink(target_name, &link_path).is_err() { eprintln!( "Warning: failed to create {} symlink at {}", link_name, link_path.display() ); } Ok(()) } } /// Check for upgrades in the background (non-blocking). /// Returns Some((latest_version)) if an upgrade is available. pub fn check_for_upgrade_prompt() -> Option<String> { // Check if disabled via environment variable if env::var("FLOW_NO_UPDATE_CHECK").is_ok() { return None; } // Check cache first let current = current_version(); if let Some(cache) = VersionCache::load() { // If current version changed, user already upgraded if cache.current_version != current { return None; } // If we've checked recently, use cached result if !cache.should_check() { if is_newer_version(current, &cache.latest_version) { return Some(cache.latest_version); } return None; } } // Perform check (with short timeout for background use) let client = Client::builder() .timeout(Duration::from_secs(5)) .build() .ok()?; let release = fetch_latest_release(&client).ok()?; let latest = parse_version(&release.tag_name).to_string(); // Update cache let cache = VersionCache { last_checked: VersionCache::now_timestamp(), latest_version: latest.clone(), current_version: current.to_string(), }; cache.save().ok(); if is_newer_version(current, &latest) { Some(latest) } else { None } } /// Print upgrade prompt if a new version is available. /// Call this at the end of command execution. pub fn maybe_print_upgrade_prompt() { // Only show on TTY if !atty::is(atty::Stream::Stderr) { return; } if let Some(latest) = check_for_upgrade_prompt() { eprintln!(); eprintln!( "A new version of flow is available: {} -> {}", current_version(), latest ); eprintln!("Run `f upgrade` to install it."); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_newer_version() { assert!(is_newer_version("0.1.0", "0.2.0")); assert!(is_newer_version("0.1.0", "1.0.0")); assert!(is_newer_version("1.0.0", "1.0.1")); assert!(is_newer_version("1.0.0", "1.1.0")); assert!(!is_newer_version("0.2.0", "0.1.0")); assert!(!is_newer_version("1.0.0", "1.0.0")); assert!(is_newer_version("v0.1.0", "v0.2.0")); } #[test] fn test_parse_version() { assert_eq!(parse_version("v1.0.0"), "1.0.0"); assert_eq!(parse_version("1.0.0"), "1.0.0"); } } ================================================ FILE: src/upstream.rs ================================================ //! Upstream fork management. //! //! Provides automated workflows for managing forks with upstream repositories. //! - `f upstream setup` - Configure upstream remote and local tracking branch //! - `f upstream pull` - Pull changes from upstream into local branch //! - `f upstream sync` - Full sync: pull upstream, merge to dev, merge to main, push use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use crate::cli::{UpstreamAction, UpstreamCommand}; /// Run the upstream subcommand. pub fn run(cmd: UpstreamCommand) -> Result<()> { let action = cmd.action.unwrap_or(UpstreamAction::Status); match action { UpstreamAction::Status => show_status(), UpstreamAction::Setup { upstream_url, upstream_branch, } => setup_upstream(upstream_url.as_deref(), upstream_branch.as_deref()), UpstreamAction::Pull { branch } => pull_upstream(branch.as_deref()), UpstreamAction::Check => check_upstream(), UpstreamAction::Sync { no_push, create_repo, } => sync_upstream(!no_push, create_repo), UpstreamAction::Open => open_upstream(), } } /// Set up upstream remote and local tracking branch, with optional fetch depth. pub fn setup_upstream_with_depth( upstream_url: Option<&str>, upstream_branch: Option<&str>, depth: Option<u32>, ) -> Result<()> { setup_upstream_internal(upstream_url, upstream_branch, depth) } /// Show current upstream configuration status. fn show_status() -> Result<()> { println!("Upstream Fork Status\n"); // Check for upstream remote let upstream_url = git_capture(&["remote", "get-url", "upstream"]).ok(); let origin_url = git_capture(&["remote", "get-url", "origin"]).ok(); if let Some(url) = &upstream_url { println!("✓ upstream remote: {}", url.trim()); } else { println!("✗ upstream remote: not configured"); } if let Some(url) = &origin_url { println!("✓ origin remote: {}", url.trim()); } // Check for local upstream branch let has_upstream_branch = git_capture(&["rev-parse", "--verify", "refs/heads/upstream"]).is_ok(); if has_upstream_branch { let tracking = git_capture(&["config", "--get", "branch.upstream.remote"]) .ok() .map(|s| s.trim().to_string()); println!("✓ local 'upstream' branch: exists (tracks {:?})", tracking); } else { println!("✗ local 'upstream' branch: not created"); } // Current branch let current = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"]) .ok() .map(|s| s.trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); println!("\nCurrent branch: {}", current); // Show divergence if upstream exists if upstream_url.is_some() { println!("\nTo set up: f upstream setup"); println!("To pull: f upstream pull"); println!("To sync: f upstream sync"); } else { println!("\nTo set up upstream:"); println!(" f upstream setup --url <upstream-repo-url>"); println!(" f upstream setup --url https://github.com/original/repo"); } Ok(()) } /// Open upstream repository URL in browser. fn open_upstream() -> Result<()> { let upstream_url = git_capture(&["remote", "get-url", "upstream"])?; let upstream_url = upstream_url.trim(); // Convert git URL to https URL if needed let https_url = if upstream_url.starts_with("git@github.com:") { upstream_url .replace("git@github.com:", "https://github.com/") .trim_end_matches(".git") .to_string() } else if upstream_url.starts_with("https://") { upstream_url.trim_end_matches(".git").to_string() } else { upstream_url.to_string() }; println!("Opening {}", https_url); #[cfg(target_os = "macos")] { Command::new("open") .arg(&https_url) .status() .context("failed to open URL")?; } #[cfg(target_os = "linux")] { Command::new("xdg-open") .arg(&https_url) .status() .context("failed to open URL")?; } #[cfg(target_os = "windows")] { Command::new("cmd") .args(["/C", "start", &https_url]) .status() .context("failed to open URL")?; } Ok(()) } /// Set up upstream remote and local tracking branch. fn setup_upstream(upstream_url: Option<&str>, upstream_branch: Option<&str>) -> Result<()> { setup_upstream_internal(upstream_url, upstream_branch, None) } fn setup_upstream_internal( upstream_url: Option<&str>, upstream_branch: Option<&str>, depth: Option<u32>, ) -> Result<()> { // Check if upstream remote exists let has_upstream = git_capture(&["remote", "get-url", "upstream"]).is_ok(); if !has_upstream { if let Some(url) = upstream_url { println!("Adding upstream remote: {}", url); git_run(&["remote", "add", "upstream", url])?; } else { // Try to detect from origin if let Ok(origin_url) = git_capture(&["remote", "get-url", "origin"]) { println!("No upstream remote configured."); println!("Current origin: {}", origin_url.trim()); println!("\nTo add upstream, run:"); println!(" f upstream setup --url <original-repo-url>"); return Ok(()); } bail!("No upstream remote. Use: f upstream setup --url <upstream-repo-url>"); } } else { let url = git_capture(&["remote", "get-url", "upstream"])?; println!("✓ upstream remote exists: {}", url.trim()); } // Fetch upstream println!("\nFetching upstream..."); if let Some(depth) = depth { let depth_str = depth.to_string(); git_run(&["fetch", "upstream", "--prune", "--depth", &depth_str])?; } else { git_run(&["fetch", "upstream", "--prune"])?; } // Determine upstream branch (explicit > HEAD > main > master) let upstream_branch = if let Some(branch) = upstream_branch { branch.to_string() } else if let Ok(head_ref) = git_capture(&["symbolic-ref", "refs/remotes/upstream/HEAD"]) { head_ref.trim().replace("refs/remotes/upstream/", "") } else if git_capture(&["rev-parse", "--verify", "refs/remotes/upstream/main"]).is_ok() { "main".to_string() } else if git_capture(&["rev-parse", "--verify", "refs/remotes/upstream/master"]).is_ok() { "master".to_string() } else { // List available branches let branches = git_capture(&["branch", "-r", "--list", "upstream/*"])?; println!("Cannot auto-detect upstream branch."); println!("Available upstream branches:"); for line in branches.lines() { println!(" {}", line.trim()); } bail!("Specify branch with: f upstream setup --branch <branch-name>"); }; // Check if upstream branch exists on remote let remote_ref = format!("refs/remotes/upstream/{}", upstream_branch); if git_capture(&["rev-parse", "--verify", &remote_ref]).is_err() { let branches = git_capture(&["branch", "-r", "--list", "upstream/*"])?; println!("Branch 'upstream/{}' not found.", upstream_branch); println!("Available upstream branches:"); for line in branches.lines() { println!(" {}", line.trim()); } bail!("Specify branch with: f upstream setup --branch <branch-name>"); } // Create or update local upstream branch let local_upstream_exists = git_capture(&["rev-parse", "--verify", "refs/heads/upstream"]).is_ok(); let upstream_ref = format!("upstream/{}", upstream_branch); if local_upstream_exists { println!( "Updating local 'upstream' branch to match {}...", upstream_ref ); let current = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"])?; let current = current.trim(); if current == "upstream" { // Already on upstream, just reset git_run(&["reset", "--hard", &upstream_ref])?; } else { // Update without switching git_run(&["branch", "-f", "upstream", &upstream_ref])?; } } else { println!( "Creating local 'upstream' branch tracking {}...", upstream_ref ); git_run(&["branch", "upstream", &upstream_ref])?; } // Set up tracking git_run(&["config", "branch.upstream.remote", "upstream"])?; git_run(&[ "config", "branch.upstream.merge", &format!("refs/heads/{}", upstream_branch), ])?; println!("\n✓ Upstream setup complete!"); println!("\nWorkflow:"); println!(" 1. f upstream pull - Pull latest from upstream into 'upstream' branch"); println!(" 2. f upstream sync - Pull, merge to dev/main, and push"); println!("\nThe local 'upstream' branch is a clean snapshot of the original repo."); println!("Your changes stay on dev/main, making merges cleaner."); Ok(()) } /// Pull changes from upstream into the local upstream branch. fn pull_upstream(target_branch: Option<&str>) -> Result<()> { // Check upstream remote exists if git_capture(&["remote", "get-url", "upstream"]).is_err() { bail!("No upstream remote. Run: f upstream setup --url <url>"); } // Fetch upstream println!("Fetching upstream..."); git_run(&["fetch", "upstream", "--prune"])?; // Determine the upstream branch to track (check config, then HEAD, then try main/master) let upstream_branch = resolve_upstream_branch()?; let upstream_ref = format!("upstream/{}", upstream_branch); // Update local upstream branch let current = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"])?; let current = current.trim(); // Check for uncommitted changes and stash if needed let mut stashed = false; let stash_count_before = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); let status = git_capture(&["status", "--porcelain"])?; if !status.trim().is_empty() { println!("Stashing local changes..."); let _ = git_run(&["stash", "push", "-m", "upstream-pull auto-stash"]); // Check if stash actually added an entry let stash_count_after = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); stashed = stash_count_after > stash_count_before; } // Update local upstream branch let local_upstream_exists = git_capture(&["rev-parse", "--verify", "refs/heads/upstream"]).is_ok(); if local_upstream_exists { if current == "upstream" { git_run(&["reset", "--hard", &upstream_ref])?; } else { git_run(&["branch", "-f", "upstream", &upstream_ref])?; } println!("✓ Updated local 'upstream' branch to {}", upstream_ref); } else { git_run(&["branch", "upstream", &upstream_ref])?; println!("✓ Created local 'upstream' branch from {}", upstream_ref); } // Optionally merge into target branch if let Some(target) = target_branch { println!("\nMerging upstream into {}...", target); if current != target { git_run(&["checkout", target])?; } if git_run(&["merge", "--ff-only", "upstream"]).is_err() { println!("Fast-forward failed, trying regular merge..."); if let Err(e) = git_run(&["merge", "upstream", "--no-edit"]) { if stashed { println!("Your changes are stashed. Run 'git stash pop' after resolving."); } return Err(e); } } println!("✓ Merged upstream into {}", target); // Return to original branch if different if current != target && current != "upstream" { git_run(&["checkout", current])?; } } // Restore stashed changes if stashed { println!("Restoring stashed changes..."); git_run(&["stash", "pop"])?; } // Show what changed let behind = git_capture(&["rev-list", "--count", &format!("HEAD..{}", upstream_ref)]) .ok() .and_then(|s| s.trim().parse::<u32>().ok()) .unwrap_or(0); if behind > 0 { println!("\nYour branch is {} commit(s) behind upstream.", behind); println!("Run 'f upstream sync' to merge and push."); } else { println!("\n✓ Up to date with upstream!"); } Ok(()) } fn resolve_upstream_branch() -> Result<String> { if let Ok(merge_ref) = git_capture(&["config", "--get", "branch.upstream.merge"]) { return Ok(merge_ref.trim().replace("refs/heads/", "")); } if let Ok(head_ref) = git_capture(&["symbolic-ref", "refs/remotes/upstream/HEAD"]) { // Parse "refs/remotes/upstream/master" -> "master" return Ok(head_ref.trim().replace("refs/remotes/upstream/", "")); } if git_capture(&["rev-parse", "--verify", "refs/remotes/upstream/main"]).is_ok() { return Ok("main".to_string()); } if git_capture(&["rev-parse", "--verify", "refs/remotes/upstream/master"]).is_ok() { return Ok("master".to_string()); } if git_capture(&["rev-parse", "--verify", "refs/remotes/upstream/dev"]).is_ok() { return Ok("dev".to_string()); } bail!("Cannot determine upstream branch. Run: f upstream setup --branch <branch>"); } fn check_upstream() -> Result<()> { if git_capture(&["remote", "get-url", "upstream"]).is_err() { bail!("No upstream remote. Run: f upstream setup --url <url>"); } println!("Fetching upstream..."); git_run(&["fetch", "upstream", "--prune"])?; let upstream_branch = resolve_upstream_branch()?; let upstream_ref = format!("upstream/{}", upstream_branch); let current = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"])?; let current = current.trim(); let mut stashed = false; let stash_count_before = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); let status = git_capture(&["status", "--porcelain"])?; if !status.trim().is_empty() { println!("Stashing local changes to check upstream..."); let _ = git_run(&["stash", "push", "-m", "upstream-check auto-stash"]); let stash_count_after = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); stashed = stash_count_after > stash_count_before; } let local_upstream_exists = git_capture(&["rev-parse", "--verify", "refs/heads/upstream"]).is_ok(); if local_upstream_exists { if current == "upstream" { git_run(&["reset", "--hard", &upstream_ref])?; } else { git_run(&["branch", "-f", "upstream", &upstream_ref])?; } println!("✓ Updated local 'upstream' branch to {}", upstream_ref); } else { git_run(&["branch", "upstream", &upstream_ref])?; println!("✓ Created local 'upstream' branch from {}", upstream_ref); } git_run(&["checkout", "upstream"])?; println!("Now on 'upstream' (tracking {}).", upstream_ref); if stashed { println!("Your changes are stashed. Run 'git stash pop' when you're ready."); } Ok(()) } /// Full sync: pull upstream, merge to dev, merge to main, push. fn sync_upstream(push: bool, create_repo: bool) -> Result<()> { // Check upstream remote exists if git_capture(&["remote", "get-url", "upstream"]).is_err() { bail!("No upstream remote. Run: f upstream setup --url <url>"); } let current = git_capture(&["rev-parse", "--abbrev-ref", "HEAD"])?; let current = current.trim().to_string(); // Check for uncommitted changes and stash if needed let mut stashed = false; let stash_count_before = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); let status = git_capture(&["status", "--porcelain"])?; if !status.trim().is_empty() { println!("Stashing local changes..."); let _ = git_run(&["stash", "push", "-m", "upstream-sync auto-stash"]); // Check if stash actually added an entry let stash_count_after = git_capture(&["stash", "list"]) .map(|s| s.lines().count()) .unwrap_or(0); stashed = stash_count_after > stash_count_before; } // Fetch upstream println!("==> Fetching upstream..."); git_run(&["fetch", "upstream", "--prune"])?; // Determine upstream branch (check config, then HEAD, then try main/master) let upstream_branch = if let Ok(merge_ref) = git_capture(&["config", "--get", "branch.upstream.merge"]) { merge_ref.trim().replace("refs/heads/", "") } else if let Ok(head_ref) = git_capture(&["symbolic-ref", "refs/remotes/upstream/HEAD"]) { // Parse "refs/remotes/upstream/master" -> "master" head_ref.trim().replace("refs/remotes/upstream/", "") } else if git_capture(&["rev-parse", "--verify", "refs/remotes/upstream/main"]).is_ok() { "main".to_string() } else if git_capture(&["rev-parse", "--verify", "refs/remotes/upstream/master"]).is_ok() { "master".to_string() } else { "main".to_string() }; let upstream_ref = format!("upstream/{}", upstream_branch); // Update local upstream branch println!("==> Updating local 'upstream' branch..."); let local_upstream_exists = git_capture(&["rev-parse", "--verify", "refs/heads/upstream"]).is_ok(); if local_upstream_exists { git_run(&["branch", "-f", "upstream", &upstream_ref])?; } else { git_run(&["branch", "upstream", &upstream_ref])?; } // Detect branch structure (dev+main or just main) let has_dev = git_capture(&["rev-parse", "--verify", "refs/heads/dev"]).is_ok(); let has_main = git_capture(&["rev-parse", "--verify", "refs/heads/main"]).is_ok(); if has_dev { // Merge upstream -> dev -> main println!("==> Merging upstream into dev..."); git_run(&["checkout", "dev"])?; merge_branch("upstream", "dev")?; if has_main { println!("==> Merging dev into main..."); git_run(&["checkout", "main"])?; merge_branch("dev", "main")?; } } else if has_main { // Just merge upstream -> main println!("==> Merging upstream into main..."); git_run(&["checkout", "main"])?; merge_branch("upstream", "main")?; } else { // Merge into current branch println!("==> Merging upstream into {}...", current); git_run(&["checkout", ¤t])?; merge_branch("upstream", ¤t)?; } // Push if requested if push { println!("==> Pushing to origin..."); // Try push, auto-create repo if it doesn't exist let branches_to_push: Vec<&str> = if has_dev && has_main { vec!["dev", "main"] } else if has_main { vec!["main"] } else if has_dev { vec!["dev"] } else { vec![current.as_str()] }; for branch in &branches_to_push { if let Err(e) = git_run(&["push", "origin", branch]) { // Only try to create repo if explicitly requested if create_repo && try_create_origin_repo()? { // Repo created, retry push git_run(&["push", "-u", "origin", branch])?; } else { return Err(e); } } } } // Return to original branch if current != "main" && current != "dev" { git_run(&["checkout", ¤t])?; } // Restore stashed changes if stashed { println!("Restoring stashed changes..."); git_run(&["stash", "pop"])?; } println!("\n✓ Sync complete!"); if has_dev && has_main { println!(" upstream, dev, and main are updated."); } else if has_main { println!(" upstream and main are updated."); } Ok(()) } /// Merge a source branch into the current branch. fn merge_branch(source: &str, target: &str) -> Result<()> { // Try fast-forward first if git_run(&["merge", "--ff-only", source]).is_ok() { return Ok(()); } println!("Fast-forward failed, trying regular merge..."); if let Err(_) = git_run(&["merge", source, "--no-edit"]) { bail!( "Merge conflicts in {}. Resolve manually:\n git status\n # fix conflicts\n git add . && git commit", target ); } Ok(()) } /// Run a git command and capture stdout. fn git_capture(args: &[&str]) -> Result<String> { let output = Command::new("git") .args(args) .output() .context("failed to run git")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git {} failed: {}", args.join(" "), stderr.trim()); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } /// Run a git command with inherited stdio. fn git_run(args: &[&str]) -> Result<()> { let status = Command::new("git") .args(args) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to run git")?; if !status.success() { bail!("git {} failed", args.join(" ")); } Ok(()) } /// Try to create the origin repo on GitHub if it doesn't exist. /// Returns true if repo was created, false if it already exists or creation failed. fn try_create_origin_repo() -> Result<bool> { // Get origin URL to extract repo name let origin_url = match git_capture(&["remote", "get-url", "origin"]) { Ok(url) => url.trim().to_string(), Err(_) => return Ok(false), }; // Extract repo name from URL (supports both SSH and HTTPS formats) // SSH: git@github.com:user/repo.git // HTTPS: https://github.com/user/repo.git let repo_path = if origin_url.starts_with("git@github.com:") { origin_url .strip_prefix("git@github.com:") .and_then(|s| s.strip_suffix(".git").or(Some(s))) } else if origin_url.contains("github.com/") { origin_url .split("github.com/") .nth(1) .and_then(|s| s.strip_suffix(".git").or(Some(s))) } else { None }; let Some(repo_path) = repo_path else { println!("Cannot parse origin URL for auto-creation: {}", origin_url); return Ok(false); }; println!("\nOrigin repo doesn't exist. Creating: {}", repo_path); // Use gh CLI to create the repo let status = Command::new("gh") .args(["repo", "create", repo_path, "--private", "--source=."]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status(); match status { Ok(s) if s.success() => { println!("✓ Created GitHub repo: {}", repo_path); Ok(true) } Ok(_) => { println!("Failed to create repo. Is `gh` installed and authenticated?"); println!(" Run: gh auth login"); Ok(false) } Err(e) => { println!("Failed to run gh CLI: {}", e); println!(" Install with: brew install gh"); Ok(false) } } } ================================================ FILE: src/url_inspect.rs ================================================ use std::path::Path; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use regex::Regex; use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{ cli::{ UrlAction, UrlCommand, UrlCrawlOpts, UrlCrawlSource, UrlInspectOpts, UrlInspectProvider, }, config, env as flow_env, http_client, project_snapshot, }; const DEFAULT_EXCERPT_CHARS: usize = 420; const DEFAULT_DIRECT_ACCEPT: &str = "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1"; const CLOUDFLARE_API_BASE: &str = "https://api.cloudflare.com/client/v4"; #[derive(Debug, Clone, Default)] struct UrlInspectSettings { scraper_base_url: Option<String>, scraper_api_key: Option<String>, cache_ttl_hours: Option<f64>, allow_direct_fallback: bool, } #[derive(Debug, Clone, Serialize)] pub struct UrlInspectResult { pub reference: String, pub provider: String, #[serde(skip_serializing_if = "Option::is_none")] pub final_url: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub status_code: Option<u16>, #[serde(skip_serializing_if = "Option::is_none")] pub content_type: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub excerpt: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub markdown: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub cache_hit: Option<bool>, } #[derive(Debug, Clone, Serialize)] pub struct UrlCrawlResult { pub reference: String, pub provider: String, pub job_id: String, pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub total: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] pub finished: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] pub browser_seconds_used: Option<f64>, pub render: bool, pub source: String, #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub records: Vec<UrlCrawlRecord>, } #[derive(Debug, Clone, Serialize)] pub struct UrlCrawlRecord { pub url: String, pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub status_code: Option<u16>, #[serde(skip_serializing_if = "Option::is_none")] pub excerpt: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub markdown: Option<String>, } #[derive(Debug, Deserialize)] struct CloudflareEnvelope { success: bool, #[serde(default)] result: Option<Value>, #[serde(default)] errors: Vec<CloudflareError>, } #[derive(Debug, Deserialize)] struct CloudflareError { #[serde(default)] code: Option<i64>, #[serde(default)] message: Option<String>, } #[derive(Debug, Deserialize)] struct ScrapeResult { success: bool, #[serde(default)] final_url: Option<String>, #[serde(default)] status_code: Option<u16>, #[serde(default)] content_type: Option<String>, #[serde(default)] title: Option<String>, #[serde(default)] text_excerpt: Option<String>, #[serde(default)] cache_hit: Option<bool>, #[serde(default)] error: Option<String>, } pub fn run(cmd: UrlCommand) -> Result<()> { match cmd.action { UrlAction::Inspect(opts) => inspect(opts), UrlAction::Crawl(opts) => crawl(opts), } } pub fn inspect_compact(url: &str, cwd: &Path) -> Result<String> { let settings = load_url_inspect_settings(cwd); let timeout = timeout_from_secs(12.0)?; let opts = UrlInspectOpts { url: url.to_string(), json: false, full: false, provider: UrlInspectProvider::Auto, timeout_s: 12.0, }; let result = inspect_url(&opts, &settings, timeout)?; Ok(render_compact_result(&result)) } fn inspect(opts: UrlInspectOpts) -> Result<()> { let cwd = std::env::current_dir().context("failed to get current directory")?; let settings = load_url_inspect_settings(&cwd); let timeout = timeout_from_secs(opts.timeout_s)?; let result = inspect_url(&opts, &settings, timeout)?; print_result(&result, opts.json, opts.full) } fn crawl(opts: UrlCrawlOpts) -> Result<()> { let cwd = std::env::current_dir().context("failed to get current directory")?; let settings = load_url_inspect_settings(&cwd); let request_timeout = Duration::from_secs_f64(opts.wait_timeout_s.clamp(5.0, 30.0)); let wait_timeout = timeout_from_secs(opts.wait_timeout_s)?; let poll_interval = timeout_from_secs(opts.poll_interval_s)?; let result = crawl_url( &opts, &settings, request_timeout, wait_timeout, poll_interval, )?; print_crawl_result(&result, opts.json, opts.full) } fn crawl_url( opts: &UrlCrawlOpts, settings: &UrlInspectSettings, request_timeout: Duration, wait_timeout: Duration, poll_interval: Duration, ) -> Result<UrlCrawlResult> { let (account_id, api_token) = cloudflare_credentials()?.ok_or_else(|| { anyhow::anyhow!( "Cloudflare crawl requires CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in shell env or Flow personal env store" ) })?; let mut result = crawl_via_cloudflare( opts, settings, request_timeout, wait_timeout, poll_interval, &account_id, &api_token, CLOUDFLARE_API_BASE, )?; if result.reference.is_empty() { result.reference = opts.url.clone(); } Ok(result) } fn print_crawl_result(result: &UrlCrawlResult, json_output: bool, full: bool) -> Result<()> { if json_output { println!( "{}", serde_json::to_string_pretty(result) .context("failed to encode crawl result as json")? ); return Ok(()); } println!("Provider: {}", result.provider); println!("Job: {}", result.job_id); println!("Status: {}", result.status); println!("URL: {}", result.reference); if let Some(total) = result.total { println!("Total: {}", total); } if let Some(finished) = result.finished { println!("Finished: {}", finished); } if let Some(browser_seconds_used) = result.browser_seconds_used { println!("Browser seconds: {:.2}", browser_seconds_used); } if !result.records.is_empty() { println!("\nRecords:"); for (index, record) in result.records.iter().enumerate() { let label = record.title.as_deref().unwrap_or(&record.url); println!("{}. {}", index + 1, label); println!(" URL: {}", record.url); println!(" Status: {}", record.status); if let Some(status_code) = record.status_code { println!(" HTTP: {}", status_code); } if let Some(excerpt) = record.excerpt.as_deref() { println!(" Excerpt: {}", excerpt); } if full && let Some(markdown) = record.markdown.as_deref() { println!("\n Markdown:\n{}\n", markdown); } } if !full && result .records .iter() .any(|record| record.markdown.is_some()) { println!("\nHint: pass --full to print markdown bodies for returned records."); } } Ok(()) } fn inspect_url( opts: &UrlInspectOpts, settings: &UrlInspectSettings, timeout: Duration, ) -> Result<UrlInspectResult> { let provider = opts.provider; let (cloudflare_creds, cloudflare_error) = match cloudflare_credentials() { Ok(value) => (value, None), Err(err) => (None, Some(format!("{err:#}"))), }; let scraper_ready = settings.scraper_base_url.is_some(); let direct_allowed = settings.allow_direct_fallback || !scraper_ready; let plan: Vec<UrlInspectProvider> = match provider { UrlInspectProvider::Auto => { let mut providers = Vec::new(); if cloudflare_creds.is_some() { providers.push(UrlInspectProvider::Cloudflare); } if scraper_ready { providers.push(UrlInspectProvider::Scraper); } providers.push(UrlInspectProvider::Direct); providers } UrlInspectProvider::Cloudflare => vec![UrlInspectProvider::Cloudflare], UrlInspectProvider::Scraper => { if settings.allow_direct_fallback { vec![UrlInspectProvider::Scraper, UrlInspectProvider::Direct] } else { vec![UrlInspectProvider::Scraper] } } UrlInspectProvider::Direct => vec![UrlInspectProvider::Direct], }; let mut errors = Vec::new(); for next in plan { match next { UrlInspectProvider::Cloudflare => { let Some((account_id, api_token)) = cloudflare_creds.clone() else { if let Some(err) = cloudflare_error.as_deref() { errors.push(format!("cloudflare: {err}")); } else { errors.push( "cloudflare: missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN" .to_string(), ); } continue; }; if account_id.trim().is_empty() || api_token.trim().is_empty() { errors.push( "cloudflare: missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN" .to_string(), ); continue; } match inspect_via_cloudflare_markdown( &opts.url, timeout, &account_id, &api_token, settings.cache_ttl_hours, CLOUDFLARE_API_BASE, opts.full, ) { Ok(result) => return Ok(result), Err(err) => errors.push(format!("cloudflare: {err:#}")), } } UrlInspectProvider::Scraper => { if let Some(base_url) = settings.scraper_base_url.as_deref() { match inspect_via_scraper( &opts.url, timeout, base_url, settings.scraper_api_key.as_deref(), opts.full, ) { Ok(result) => return Ok(result), Err(err) => { errors.push(format!("scraper: {err:#}")); if !direct_allowed && provider == UrlInspectProvider::Scraper { break; } } } } else { errors.push("scraper: no scraper_base_url configured".to_string()); } } UrlInspectProvider::Direct => { match inspect_via_direct_fetch(&opts.url, timeout, opts.full) { Ok(result) => return Ok(result), Err(err) => errors.push(format!("direct: {err:#}")), } } UrlInspectProvider::Auto => {} } } if errors.is_empty() { bail!("url inspect failed with no available providers"); } bail!("url inspect failed:\n- {}", errors.join("\n- ")) } fn print_result(result: &UrlInspectResult, json_output: bool, full: bool) -> Result<()> { if json_output { println!( "{}", serde_json::to_string_pretty(result).context("failed to encode result as json")? ); return Ok(()); } println!("Provider: {}", result.provider); if let Some(title) = &result.title { println!("Title: {title}"); } if let Some(final_url) = &result.final_url { println!("URL: {final_url}"); } else { println!("URL: {}", result.reference); } if let Some(content_type) = &result.content_type { println!("Content-Type: {content_type}"); } if let Some(description) = &result.description { println!("\nDescription:\n{description}"); } if let Some(excerpt) = &result.excerpt { println!("\nExcerpt:\n{excerpt}"); } if full { if let Some(markdown) = &result.markdown { println!("\nMarkdown:\n{markdown}"); } } else if result.markdown.is_some() { println!("\nHint: pass --full to print the full markdown body."); } Ok(()) } fn render_compact_result(result: &UrlInspectResult) -> String { let mut lines = Vec::new(); lines.push(format!("- URL: {}", result.reference)); lines.push(format!("- Provider: {}", result.provider)); if let Some(final_url) = result.final_url.as_deref() && final_url != result.reference { lines.push(format!("- Final URL: {final_url}")); } if let Some(title) = result.title.as_deref() { lines.push(format!("- Title: {title}")); } if let Some(description) = result.description.as_deref() { lines.push(format!("- Description: {description}")); } if let Some(excerpt) = result.excerpt.as_deref() { lines.push(format!("- Excerpt: {excerpt}")); } if let Some(content_type) = result.content_type.as_deref() { lines.push(format!("- Content-Type: {content_type}")); } lines.join("\n") } fn crawl_via_cloudflare( opts: &UrlCrawlOpts, settings: &UrlInspectSettings, request_timeout: Duration, wait_timeout: Duration, poll_interval: Duration, account_id: &str, api_token: &str, api_base: &str, ) -> Result<UrlCrawlResult> { let client = http_client::blocking_with_timeout(request_timeout)?; let endpoint = format!( "{}/accounts/{}/browser-rendering/crawl", api_base.trim_end_matches('/'), account_id ); let create_payload = cloudflare_crawl_create_payload(opts, settings); let response = client .post(&endpoint) .header(CONTENT_TYPE, "application/json") .header(AUTHORIZATION, format!("Bearer {api_token}")) .json(&create_payload) .send() .context("failed to create Cloudflare Browser Rendering crawl job")?; let status = response.status(); let body = response .text() .context("failed to read Cloudflare crawl create response")?; if !status.is_success() { bail!("http {}: {}", status.as_u16(), body); } let envelope: CloudflareEnvelope = serde_json::from_str(&body).context("failed to decode Cloudflare crawl create response")?; if !envelope.success { bail!( "Cloudflare crawl failed: {}", cloudflare_error_detail(&envelope.errors) ); } let job_id = envelope .result .as_ref() .and_then(|value| value.as_str().map(|v| v.to_string())) .or_else(|| { envelope.result.as_ref().and_then(|value| { value .as_object() .and_then(|obj| obj.get("id")) .and_then(|value| value.as_str()) .map(|value| value.to_string()) }) }) .filter(|value| !value.is_empty()) .ok_or_else(|| anyhow::anyhow!("Cloudflare crawl did not return a job id"))?; let started = Instant::now(); loop { let state = fetch_cloudflare_crawl_result(&client, &endpoint, api_token, &job_id, 1, false, false)?; match state.status.as_str() { "completed" => { return fetch_cloudflare_crawl_result( &client, &endpoint, api_token, &job_id, opts.records.max(1), true, opts.full, ); } "failed" | "cancelled" | "canceled" => { bail!( "Cloudflare crawl job {} ended with status {}", job_id, state.status ); } _ => { if started.elapsed() >= wait_timeout { bail!( "timed out waiting for Cloudflare crawl job {} after {:.1}s", job_id, wait_timeout.as_secs_f64() ); } std::thread::sleep(poll_interval); } } } } fn cloudflare_crawl_create_payload( opts: &UrlCrawlOpts, settings: &UrlInspectSettings, ) -> serde_json::Value { let max_age_s = opts.max_age_s.or_else(|| { settings .cache_ttl_hours .map(|hours| (hours * 3600.0).round().clamp(0.0, 86_400.0) as u64) }); let mut payload = json!({ "url": opts.url, "limit": opts.limit, "depth": opts.depth, "render": opts.render, "source": cloudflare_crawl_source(opts.source), "formats": ["markdown"], }); if let Some(max_age_s) = max_age_s { payload["maxAge"] = json!(max_age_s); } let mut options = serde_json::Map::new(); if opts.include_external_links { options.insert("includeExternalLinks".to_string(), json!(true)); } if opts.include_subdomains { options.insert("includeSubdomains".to_string(), json!(true)); } if !opts.include_patterns.is_empty() { options.insert("includePatterns".to_string(), json!(opts.include_patterns)); } if !opts.exclude_patterns.is_empty() { options.insert("excludePatterns".to_string(), json!(opts.exclude_patterns)); } if !options.is_empty() { payload["options"] = Value::Object(options); } payload } fn cloudflare_crawl_source(source: UrlCrawlSource) -> &'static str { match source { UrlCrawlSource::All => "all", UrlCrawlSource::Sitemaps => "sitemaps", UrlCrawlSource::Links => "links", } } fn fetch_cloudflare_crawl_result( client: &reqwest::blocking::Client, endpoint: &str, api_token: &str, job_id: &str, records_limit: usize, completed_only: bool, include_full_markdown: bool, ) -> Result<UrlCrawlResult> { let mut request = client .get(format!("{}/{}", endpoint.trim_end_matches('/'), job_id)) .header(AUTHORIZATION, format!("Bearer {api_token}")) .query(&[("limit", records_limit.max(1).to_string())]); if completed_only { request = request.query(&[("status", "completed")]); } let response = request .send() .context("failed to fetch Cloudflare crawl status")?; let status = response.status(); let body = response .text() .context("failed to read Cloudflare crawl status response")?; if !status.is_success() { bail!("http {}: {}", status.as_u16(), body); } let envelope: CloudflareEnvelope = serde_json::from_str(&body).context("failed to decode Cloudflare crawl status response")?; if !envelope.success { bail!( "Cloudflare crawl status failed: {}", cloudflare_error_detail(&envelope.errors) ); } let Some(result) = envelope.result.as_ref() else { bail!("Cloudflare crawl status returned no result"); }; parse_cloudflare_crawl_result(result, include_full_markdown) } fn parse_cloudflare_crawl_result( value: &Value, include_full_markdown: bool, ) -> Result<UrlCrawlResult> { let object = value .as_object() .ok_or_else(|| anyhow::anyhow!("Cloudflare crawl result was not an object"))?; let job_id = string_field(object, "id") .ok_or_else(|| anyhow::anyhow!("Cloudflare crawl result missing id"))?; let status = string_field(object, "status").unwrap_or_else(|| "unknown".to_string()); let total = u64_field(object, "total"); let finished = u64_field(object, "finished"); let browser_seconds_used = f64_field(object, "browserSecondsUsed"); let cursor = string_field(object, "cursor"); let render = bool_field(object, "render").unwrap_or(false); let source = string_field(object, "source").unwrap_or_else(|| "all".to_string()); let reference = string_field(object, "url").unwrap_or_default(); let records = object .get("records") .and_then(|records| records.as_array()) .map(|records| { records .iter() .filter_map(|record| parse_cloudflare_crawl_record(record, include_full_markdown)) .collect::<Vec<_>>() }) .unwrap_or_default(); Ok(UrlCrawlResult { reference, provider: "cloudflare-crawl".to_string(), job_id, status, total, finished, browser_seconds_used, render, source, cursor, records, }) } fn parse_cloudflare_crawl_record( value: &Value, include_full_markdown: bool, ) -> Option<UrlCrawlRecord> { let object = value.as_object()?; let metadata = object.get("metadata").and_then(|value| value.as_object()); let url = string_field(object, "url") .or_else(|| metadata.and_then(|value| string_field(value, "url")))?; let status = string_field(object, "status").unwrap_or_else(|| "unknown".to_string()); let title = metadata.and_then(|value| string_field(value, "title")); let status_code = metadata .and_then(|value| u64_field(value, "status")) .and_then(|value| u16::try_from(value).ok()); let markdown = object .get("markdown") .and_then(|value| value.as_str()) .map(|value| value.to_string()); let excerpt = markdown .as_deref() .map(markdown_metadata) .and_then(|metadata| metadata.description.or(metadata.excerpt)); Some(UrlCrawlRecord { url, status, title, status_code, excerpt, markdown: include_full_markdown.then_some(markdown).flatten(), }) } fn cloudflare_error_detail(errors: &[CloudflareError]) -> String { let detail = errors .iter() .map(|err| match (&err.code, &err.message) { (Some(code), Some(message)) => format!("{code}: {message}"), (_, Some(message)) => message.clone(), _ => "unknown Cloudflare error".to_string(), }) .collect::<Vec<_>>() .join("; "); if detail.is_empty() { "unknown Cloudflare error".to_string() } else { detail } } fn inspect_via_cloudflare_markdown( url: &str, timeout: Duration, account_id: &str, api_token: &str, cache_ttl_hours: Option<f64>, api_base: &str, include_full_markdown: bool, ) -> Result<UrlInspectResult> { let client = http_client::blocking_with_timeout(timeout)?; let endpoint = format!( "{}/accounts/{}/browser-rendering/markdown", api_base.trim_end_matches('/'), account_id ); let mut request = client .post(endpoint) .header(CONTENT_TYPE, "application/json") .header(AUTHORIZATION, format!("Bearer {api_token}")) .json(&json!({ "url": url })); if let Some(hours) = cache_ttl_hours { let ttl = (hours * 3600.0).round().clamp(0.0, 86_400.0) as u32; request = request.query(&[("cacheTTL", ttl.to_string())]); } let response = request .send() .context("failed to call Cloudflare Browser Rendering markdown endpoint")?; let status = response.status(); let body = response .text() .context("failed to read Cloudflare markdown response")?; if !status.is_success() { bail!("http {}: {}", status.as_u16(), body); } let envelope: CloudflareEnvelope = serde_json::from_str(&body).context("failed to decode Cloudflare markdown response")?; if !envelope.success { bail!( "Cloudflare markdown failed: {}", cloudflare_error_detail(&envelope.errors) ); } let markdown = envelope .result .as_ref() .and_then(|value| value.as_str()) .unwrap_or_default() .to_string(); let metadata = markdown_metadata(&markdown); Ok(UrlInspectResult { reference: url.to_string(), provider: "cloudflare-markdown".to_string(), final_url: Some(url.to_string()), status_code: Some(status.as_u16()), content_type: Some("text/markdown".to_string()), title: metadata.title, description: metadata.description, excerpt: metadata.excerpt, markdown: include_full_markdown.then_some(markdown), cache_hit: None, }) } fn inspect_via_scraper( url: &str, timeout: Duration, base_url: &str, api_key: Option<&str>, _include_full_markdown: bool, ) -> Result<UrlInspectResult> { let client = http_client::blocking_with_timeout(timeout)?; let endpoint = format!("{}/scrape", base_url.trim_end_matches('/')); let mut request = client.post(endpoint).json(&json!({ "url": url, "mode": "balanced", "timeout_s": timeout.as_secs_f64(), "max_bytes": 400_000_u64 })); let api_token = api_key .map(|value| value.to_string()) .or_else(|| std::env::var("SEQ_SCRAPER_API_KEY").ok()); if let Some(token) = api_token { request = request.bearer_auth(token); } let response = request .send() .context("failed to call configured scraper endpoint")?; let status = response.status(); let body = response .text() .context("failed to read scraper response body")?; if !status.is_success() { bail!("http {}: {}", status.as_u16(), body); } let payload: ScrapeResult = serde_json::from_str(&body).context("failed to decode scraper response")?; if !payload.success { bail!( "{}", payload .error .unwrap_or_else(|| "scraper reported failure without an error".to_string()) ); } Ok(UrlInspectResult { reference: url.to_string(), provider: "scraper".to_string(), final_url: payload.final_url, status_code: payload.status_code, content_type: payload.content_type, title: payload.title, description: None, excerpt: payload .text_excerpt .map(|value| truncate_excerpt(&normalize_whitespace(&value))), markdown: None, cache_hit: payload.cache_hit, }) } fn inspect_via_direct_fetch( url: &str, timeout: Duration, include_full_markdown: bool, ) -> Result<UrlInspectResult> { let client = http_client::blocking_with_timeout(timeout)?; let response = client .get(url) .header(ACCEPT, DEFAULT_DIRECT_ACCEPT) .send() .with_context(|| format!("failed to fetch {url}"))?; let status = response.status(); let final_url = response.url().to_string(); let content_type = header_value(response.headers().get(CONTENT_TYPE)); let markdown_tokens = header_value(response.headers().get("x-markdown-tokens")); let body = response.text().context("failed to read response body")?; if !status.is_success() { bail!("http {}: {}", status.as_u16(), truncate_excerpt(&body)); } let looks_like_markdown = content_type .as_deref() .map(|value| value.contains("markdown")) .unwrap_or(false) || markdown_tokens.is_some(); let (title, description, excerpt, markdown) = if looks_like_markdown { let metadata = markdown_metadata(&body); ( metadata.title, metadata.description, metadata.excerpt, include_full_markdown.then_some(body), ) } else if content_type .as_deref() .map(|value| value.contains("html")) .unwrap_or(false) || body.contains("<html") || body.contains("<body") { let html = html_metadata(&body, &final_url); ( html.title, html.description, html.excerpt, include_full_markdown.then_some(body), ) } else { let excerpt = truncate_excerpt(&normalize_whitespace(&body)); ( None, None, Some(excerpt), include_full_markdown.then_some(body), ) }; Ok(UrlInspectResult { reference: url.to_string(), provider: "direct".to_string(), final_url: Some(final_url), status_code: Some(status.as_u16()), content_type, title, description, excerpt, markdown, cache_hit: None, }) } fn timeout_from_secs(seconds: f64) -> Result<Duration> { if !seconds.is_finite() || seconds <= 0.0 { bail!("timeout must be a positive finite number"); } Ok(Duration::from_secs_f64(seconds)) } fn cloudflare_credentials() -> Result<Option<(String, String)>> { let account_id = load_secret_env_var("CLOUDFLARE_ACCOUNT_ID")?; let api_token = load_secret_env_var("CLOUDFLARE_API_TOKEN")?; match (account_id, api_token) { (Some(account_id), Some(api_token)) => Ok(Some((account_id, api_token))), (None, None) => Ok(None), (Some(_), None) => { bail!("missing CLOUDFLARE_API_TOKEN; set it in shell env or Flow personal env store") } (None, Some(_)) => { bail!("missing CLOUDFLARE_ACCOUNT_ID; set it in shell env or Flow personal env store") } } } fn load_secret_env_var(key: &str) -> Result<Option<String>> { if let Ok(value) = std::env::var(key) { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(Some(trimmed.to_string())); } } let primary = flow_env::get_personal_env_var(key) .with_context(|| format!("failed to load {key} from Flow personal env store")); match primary { Ok(Some(value)) => { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(Some(trimmed.to_string())); } } Ok(None) => {} Err(err) if wants_local_env_backend() => return Err(err), Err(_) => {} } if !wants_local_env_backend() { let local_value = with_local_env_backend(|| flow_env::get_personal_env_var(key)) .with_context(|| format!("failed to load {key} from local Flow personal env store"))?; if let Some(value) = local_value { let trimmed = value.trim(); if !trimmed.is_empty() { return Ok(Some(trimmed.to_string())); } } } Ok(None) } fn wants_local_env_backend() -> bool { if let Some(backend) = crate::config::preferred_env_backend() { return backend == "local"; } if let Ok(value) = std::env::var("FLOW_ENV_BACKEND") { return value.trim().eq_ignore_ascii_case("local"); } std::env::var("FLOW_ENV_LOCAL") .ok() .map(|value| value.trim() == "1" || value.trim().eq_ignore_ascii_case("true")) .unwrap_or(false) } fn with_local_env_backend<T>(action: impl FnOnce() -> Result<T>) -> Result<T> { let previous = std::env::var("FLOW_ENV_BACKEND").ok(); unsafe { std::env::set_var("FLOW_ENV_BACKEND", "local"); } let result = action(); unsafe { match previous { Some(value) => std::env::set_var("FLOW_ENV_BACKEND", value), None => std::env::remove_var("FLOW_ENV_BACKEND"), } } result } fn string_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<String> { object .get(key) .and_then(|value| value.as_str()) .map(|value| value.to_string()) .filter(|value| !value.is_empty()) } fn u64_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<u64> { object.get(key).and_then(|value| match value { Value::Number(number) => number.as_u64(), Value::String(text) => text.parse::<u64>().ok(), _ => None, }) } fn f64_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<f64> { object.get(key).and_then(|value| match value { Value::Number(number) => number.as_f64(), Value::String(text) => text.parse::<f64>().ok(), _ => None, }) } fn bool_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<bool> { object.get(key).and_then(|value| match value { Value::Bool(value) => Some(*value), Value::String(text) => match text.as_str() { "true" => Some(true), "false" => Some(false), _ => None, }, _ => None, }) } fn load_url_inspect_settings(cwd: &Path) -> UrlInspectSettings { let mut settings = UrlInspectSettings::default(); let global_path = config::default_config_path(); if global_path.exists() { let cfg = config::load_or_default(&global_path); merge_seq_settings(&mut settings, cfg.skills.and_then(|skills| skills.seq)); } if let Some(local_flow_toml) = project_snapshot::find_flow_toml_upwards(cwd) { let cfg = config::load_or_default(&local_flow_toml); merge_seq_settings(&mut settings, cfg.skills.and_then(|skills| skills.seq)); } settings } fn merge_seq_settings(settings: &mut UrlInspectSettings, seq_cfg: Option<config::SkillsSeqConfig>) { let Some(seq_cfg) = seq_cfg else { return; }; if let Some(value) = seq_cfg.scraper_base_url { settings.scraper_base_url = Some(value); } if let Some(value) = seq_cfg.scraper_api_key { settings.scraper_api_key = Some(value); } if let Some(value) = seq_cfg.cache_ttl_hours { settings.cache_ttl_hours = Some(value); } if let Some(value) = seq_cfg.allow_direct_fallback { settings.allow_direct_fallback = value; } } #[derive(Debug, Default)] struct TextMetadata { title: Option<String>, description: Option<String>, excerpt: Option<String>, } fn markdown_metadata(markdown: &str) -> TextMetadata { let (frontmatter, content) = extract_markdown_frontmatter(markdown); let mut title = None; let mut description = None; let mut headings = Vec::new(); let mut pre_heading_lines = Vec::new(); let mut post_heading_lines = Vec::new(); let mut heading_seen = false; if let Some(frontmatter) = frontmatter.as_deref() { title = capture_frontmatter_value(frontmatter, "title"); description = capture_frontmatter_value(frontmatter, "description") .or_else(|| capture_frontmatter_value(frontmatter, "summary")); } for raw_line in content.lines() { let line = raw_line.trim(); if line.is_empty() { continue; } if looks_like_markdown_boilerplate(line) { continue; } if line.starts_with('#') { headings.push(line.to_string()); heading_seen = true; continue; } if line.starts_with("```") { continue; } if looks_like_markdown_metadata_line(line) { continue; } if heading_seen { post_heading_lines.push(line.to_string()); } else { pre_heading_lines.push(line.to_string()); } } let first_heading = headings.iter().find_map(|heading| { let text = heading.trim_start_matches('#').trim(); (!text.is_empty()).then(|| text.to_string()) }); if title.is_none() { title = first_heading.clone(); } let paragraphs = if !post_heading_lines.is_empty() { post_heading_lines } else { pre_heading_lines }; if description.is_none() && let Some(first) = paragraphs.first() { description = Some(truncate_excerpt(first)); } let excerpt_source = paragraphs.join(" "); let excerpt = (!excerpt_source.is_empty()).then(|| truncate_excerpt(&excerpt_source)); TextMetadata { title, description, excerpt, } } fn looks_like_markdown_boilerplate(line: &str) -> bool { let trimmed = line.trim(); if trimmed.is_empty() { return true; } let lower = trimmed.to_ascii_lowercase(); if lower == "search" || lower.starts_with("[skip to content]") || lower.starts_with("search ") || lower.contains("subscribe to rss") || lower.contains("view rss feeds") || lower.contains("select theme") || lower.contains("docs directory") || lower.contains("new updates and improvements at cloudflare") || lower == "help" || lower.contains("back to all posts") || lower.starts_with("![") || lower.starts_with("[ ![](") || (trimmed.starts_with('[') && trimmed.matches("](").count() >= 2) { return true; } matches!(trimmed, "# Changelog" | "## Changelog") } fn looks_like_markdown_metadata_line(line: &str) -> bool { let trimmed = line.trim(); if trimmed.is_empty() { return true; } let lower = trimmed.to_ascii_lowercase(); if lower.starts_with("_edit:") || lower.starts_with("*edit:") || lower.starts_with("edit:") || is_date_only_line(trimmed) { return true; } is_single_markdown_link_line(trimmed) } fn is_date_only_line(line: &str) -> bool { let Some((month, rest)) = line.split_once(' ') else { return false; }; if !matches!( month, "Jan" | "January" | "Feb" | "February" | "Mar" | "March" | "Apr" | "April" | "May" | "Jun" | "June" | "Jul" | "July" | "Aug" | "August" | "Sep" | "Sept" | "September" | "Oct" | "October" | "Nov" | "November" | "Dec" | "December" ) { return false; } let Some((day, year)) = rest.split_once(',') else { return false; }; let day = day.trim(); let year = year.trim(); !day.is_empty() && day.chars().all(|ch| ch.is_ascii_digit()) && year.len() == 4 && year.chars().all(|ch| ch.is_ascii_digit()) } fn is_single_markdown_link_line(line: &str) -> bool { let Some(rest) = line.strip_prefix('[') else { return false; }; let Some((label, url)) = rest.split_once("](") else { return false; }; let Some(url) = url.strip_suffix(')') else { return false; }; !label.trim().is_empty() && !url.trim().is_empty() && !label.contains('[') && !url.contains('(') && !url.contains(')') } fn html_metadata(html: &str, final_url: &str) -> TextMetadata { let title = capture_first(r"(?is)<title[^>]*>(.*?)", html) .map(|value| normalize_whitespace(&value)) .filter(|value| !value.is_empty()); let description = capture_meta_description(html) .map(|value| normalize_whitespace(&value)) .filter(|value| !value.is_empty()) .map(|value| truncate_excerpt(&value)); let excerpt = { let without_scripts = replace_all(r"(?is)<(script|style)[^>]*>.*?", html, " "); let without_tags = replace_all(r"(?is)<[^>]+>", &without_scripts, " "); let normalized = normalize_whitespace(&without_tags); (!normalized.is_empty()).then(|| truncate_excerpt(&normalized)) }; let mut metadata = TextMetadata { title, description, excerpt, }; if looks_like_js_app_shell(final_url, html, &metadata) { if metadata.description.is_none() { metadata.description = Some( "JavaScript-heavy app shell; direct fetch could not extract structured page content. Prefer Browser Rendering markdown, a configured scraper, or a domain-specific resolver." .to_string(), ); } metadata.excerpt = None; if final_url.contains("linear.app/") && metadata.title.as_deref() == Some("Linear") { metadata.title = Some("Linear (app shell)".to_string()); } } metadata } fn capture_meta_description(html: &str) -> Option { capture_first( r#"(?is)]+(?:name|property)\s*=\s*["'](?:description|og:description)["'][^>]+content\s*=\s*["'](.*?)["'][^>]*>"#, html, ) .or_else(|| { capture_first( r#"(?is)]+content\s*=\s*["'](.*?)["'][^>]+(?:name|property)\s*=\s*["'](?:description|og:description)["'][^>]*>"#, html, ) }) } fn capture_first(pattern: &str, haystack: &str) -> Option { let regex = Regex::new(pattern).ok()?; let captures = regex.captures(haystack)?; captures .get(1) .map(|capture| capture.as_str().trim().to_string()) } fn replace_all(pattern: &str, haystack: &str, replacement: &str) -> String { Regex::new(pattern) .map(|regex| regex.replace_all(haystack, replacement).into_owned()) .unwrap_or_else(|_| haystack.to_string()) } fn normalize_whitespace(input: &str) -> String { input .split_whitespace() .filter(|segment| !segment.is_empty()) .collect::>() .join(" ") } fn truncate_excerpt(input: &str) -> String { let normalized = normalize_whitespace(input); if normalized.chars().count() <= DEFAULT_EXCERPT_CHARS { return normalized; } let truncated: String = normalized.chars().take(DEFAULT_EXCERPT_CHARS).collect(); format!("{}...", truncated.trim_end()) } fn extract_markdown_frontmatter(markdown: &str) -> (Option, String) { let mut lines = markdown.lines(); let Some(first) = lines.next() else { return (None, markdown.to_string()); }; if first.trim() != "---" { return (None, markdown.to_string()); } let mut frontmatter = Vec::new(); let mut remainder = Vec::new(); let mut found_closing = false; for line in lines { if !found_closing && line.trim() == "---" { found_closing = true; continue; } if found_closing { remainder.push(line); } else { frontmatter.push(line); } } if !found_closing { return (None, markdown.to_string()); } (Some(frontmatter.join("\n")), remainder.join("\n")) } fn capture_frontmatter_value(frontmatter: &str, key: &str) -> Option { let pattern = format!(r"(?mi)^\s*{}\s*:\s*(.+?)\s*$", regex::escape(key)); let value = capture_first(&pattern, frontmatter)?; let value = value.trim().trim_matches('"').trim_matches('\''); (!value.is_empty()).then(|| value.to_string()) } fn looks_like_js_app_shell(final_url: &str, html: &str, metadata: &TextMetadata) -> bool { let title = metadata.title.as_deref().unwrap_or_default(); let excerpt = metadata.excerpt.as_deref().unwrap_or_default(); (final_url.contains("linear.app/") && title == "Linear") || metadata.description.is_none() && (excerpt.contains("performance.mark(\"appStart\")") || excerpt.contains("--bg-sidebar-light") || excerpt.contains("--bg-base-color-dark") || html.contains("performance.mark(\"appStart\")") || html.contains("--bg-sidebar-light")) } fn header_value(value: Option<&reqwest::header::HeaderValue>) -> Option { value .and_then(|header| header.to_str().ok()) .map(|value| value.to_string()) } #[cfg(test)] mod tests { use super::*; use mockito::Server; #[test] fn markdown_metadata_prefers_first_heading_and_excerpt() { let metadata = markdown_metadata( "# Example Title\n\nFirst paragraph with useful context.\n\nSecond paragraph.", ); assert_eq!(metadata.title.as_deref(), Some("Example Title")); assert_eq!( metadata.description.as_deref(), Some("First paragraph with useful context.") ); assert!( metadata .excerpt .as_deref() .unwrap_or_default() .contains("First paragraph with useful context.") ); } #[test] fn markdown_metadata_reads_frontmatter_title_and_description() { let metadata = markdown_metadata( "---\n\ title: Crawl entire websites with a single API call using Browser Rendering\n\ description: Browser Rendering's new /crawl endpoint crawls and renders a site.\n\ ---\n\n# Ignored Heading\n\nBody paragraph.", ); assert_eq!( metadata.title.as_deref(), Some("Crawl entire websites with a single API call using Browser Rendering") ); assert_eq!( metadata.description.as_deref(), Some("Browser Rendering's new /crawl endpoint crawls and renders a site.") ); assert!( metadata .excerpt .as_deref() .unwrap_or_default() .contains("Body paragraph.") ); } #[test] fn direct_fetch_extracts_html_metadata() { let mut server = Server::new(); let _mock = server .mock("GET", "/page") .with_status(200) .with_header("content-type", "text/html; charset=utf-8") .with_body( r#" Flow URL Inspect
Useful body text for the excerpt.
"#, ) .create(); let result = inspect_via_direct_fetch( &format!("{}/page", server.url()), Duration::from_secs(5), false, ) .expect("direct fetch should succeed"); assert_eq!(result.title.as_deref(), Some("Flow URL Inspect")); assert_eq!( result.description.as_deref(), Some("Thin summaries for AI sessions.") ); assert!( result .excerpt .as_deref() .unwrap_or_default() .contains("Useful body text") ); } #[test] fn html_metadata_detects_linear_app_shell() { let metadata = html_metadata( r#" Linear "#, "https://linear.app/fl2024008/project/example/overview", ); assert_eq!(metadata.title.as_deref(), Some("Linear (app shell)")); assert!( metadata .description .as_deref() .unwrap_or_default() .contains("JavaScript-heavy app shell") ); assert!(metadata.excerpt.is_none()); } #[test] fn cloudflare_markdown_normalizes_result() { let mut server = Server::new(); let _mock = server .mock("POST", "/accounts/test-account/browser-rendering/markdown") .match_query(mockito::Matcher::UrlEncoded("cacheTTL".into(), "7200".into())) .with_status(200) .with_header("content-type", "application/json") .with_body( "{\n \"success\": true,\n \"result\": \"# Cloudflare Page\\n\\nRendered into markdown.\"\n}", ) .create(); let result = inspect_via_cloudflare_markdown( "https://example.com/docs", Duration::from_secs(5), "test-account", "secret-token", Some(2.0), &server.url(), false, ) .expect("cloudflare markdown should succeed"); assert_eq!(result.provider, "cloudflare-markdown"); assert_eq!(result.title.as_deref(), Some("Cloudflare Page")); assert_eq!( result.description.as_deref(), Some("Rendered into markdown.") ); } #[test] fn markdown_metadata_skips_changelog_boilerplate() { let metadata = markdown_metadata(concat!( "[Skip to content](#_top)\n", "Search\n", "[Docs Directory](https://example.com/directory)[APIs](https://example.com/api)Help\n", "# Changelog\n", "New updates and improvements at Cloudflare.\n", "[ Subscribe to RSS ](https://example.com/rss)\n", "![hero image](https://example.com/hero.svg)\n", "[ ← Back to all posts ](https://example.com)\n", "## Crawl entire websites with a single API call using Browser Rendering\n\n", "Mar 10, 2026\n", "[ Browser Rendering ](https://example.com/browser-rendering)\n", "_Edit: this post has been edited to clarify crawling behavior._\n\n", "Browser Rendering's new /crawl endpoint lets you submit a starting URL and automatically discover content.\n", )); assert_eq!( metadata.title.as_deref(), Some("Crawl entire websites with a single API call using Browser Rendering") ); assert_eq!( metadata.description.as_deref(), Some( "Browser Rendering's new /crawl endpoint lets you submit a starting URL and automatically discover content." ) ); } #[test] fn parse_cloudflare_crawl_result_extracts_records() { let payload = json!({ "id": "crawl-job-123", "status": "completed", "url": "https://developers.cloudflare.com/browser-rendering/", "total": 3, "finished": 3, "browserSecondsUsed": 0.72, "render": false, "source": "all", "records": [ { "url": "https://developers.cloudflare.com/browser-rendering/rest-api/crawl-endpoint/", "status": "completed", "markdown": "# Crawl endpoint\n\nCloudflare can crawl and return markdown.", "metadata": { "title": "Crawl endpoint", "status": 200 } } ] }); let result = parse_cloudflare_crawl_result(&payload, false).expect("crawl result should parse"); assert_eq!(result.provider, "cloudflare-crawl"); assert_eq!(result.job_id, "crawl-job-123"); assert_eq!(result.status, "completed"); assert_eq!(result.total, Some(3)); assert_eq!(result.finished, Some(3)); assert_eq!(result.records.len(), 1); assert_eq!(result.records[0].title.as_deref(), Some("Crawl endpoint")); assert_eq!(result.records[0].status_code, Some(200)); assert_eq!( result.records[0].excerpt.as_deref(), Some("Cloudflare can crawl and return markdown.") ); assert!(result.records[0].markdown.is_none()); } } ================================================ FILE: src/usage.rs ================================================ use std::collections::{BTreeSet, HashSet}; use std::fs::{self, OpenOptions}; use std::io::{self, BufRead, BufReader, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::Sha256; use uuid::Uuid; use crate::config; use crate::http_client; const DEFAULT_ANALYTICS_ENDPOINT: &str = "https://api.myflow.sh/api/telemetry/flow"; const QUEUE_FILE_NAME: &str = "usage-queue.jsonl"; const STATE_FILE_NAME: &str = "analytics.toml"; const MAX_QUEUE_BYTES: usize = 10 * 1024 * 1024; const MAX_BATCH_SIZE: usize = 100; const ANON_ROTATION_DAYS: u64 = 30; static FLUSH_IN_PROGRESS: AtomicBool = AtomicBool::new(false); type HmacSha256 = Hmac; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum AnalyticsConsent { Unknown, Enabled, Disabled, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalyticsState { pub consent: AnalyticsConsent, pub install_id: String, pub local_secret: String, pub prompted_at_ms: Option, pub updated_at_ms: u64, } impl AnalyticsState { fn new_unknown() -> Self { Self { consent: AnalyticsConsent::Unknown, install_id: Uuid::new_v4().to_string(), local_secret: Uuid::new_v4().to_string(), prompted_at_ms: None, updated_at_ms: now_ms(), } } } #[derive(Debug, Clone)] pub struct AnalyticsRuntimeConfig { pub enabled: Option, pub endpoint: String, pub sample_rate: f32, } #[derive(Debug, Clone)] pub struct CommandCapture { pub at_ms: u64, pub command_path: String, pub flags_used: Vec, pub interactive: bool, pub ci: bool, pub flow_version: String, pub os: String, pub arch: String, } #[derive(Debug, Clone)] pub struct AnalyticsStatus { pub consent: AnalyticsConsent, pub effective_enabled: bool, pub install_id: String, pub endpoint: String, pub queue_path: PathBuf, pub queued_events: usize, } #[derive(Debug, Deserialize, Default)] struct AnalyticsConfigProbe { #[serde(default)] analytics: Option, #[serde(default, rename = "commands")] command_files: Vec, } pub fn command_capture(raw_args: &[String]) -> CommandCapture { CommandCapture { at_ms: now_ms(), command_path: command_path(raw_args), flags_used: extract_flags(raw_args), interactive: io::stdin().is_terminal() && io::stdout().is_terminal(), ci: env_flag("CI"), flow_version: env!("CARGO_PKG_VERSION").to_string(), os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), } } pub fn is_analytics_command(raw_args: &[String]) -> bool { if let Some(command) = raw_args.iter().skip(1).find(|arg| !arg.starts_with('-')) { return command == "analytics"; } command_path(raw_args).starts_with("analytics") } pub fn maybe_prompt_for_opt_in(is_analytics_command: bool, succeeded: bool) { if !succeeded || is_analytics_command { return; } if env_flag("FLOW_ANALYTICS_DISABLE") || env_flag("FLOW_ANALYTICS_FORCE") { return; } if !io::stdin().is_terminal() || !io::stdout().is_terminal() { return; } let mut state = match load_or_init_state() { Ok(state) => state, Err(_) => return, }; if state.consent != AnalyticsConsent::Unknown || state.prompted_at_ms.is_some() { return; } print!("Enable anonymous usage tracking to improve Flow? [y/N/later]: "); let _ = io::stdout().flush(); let mut input = String::new(); if io::stdin().read_line(&mut input).is_err() { return; } let answer = input.trim().to_ascii_lowercase(); match answer.as_str() { "y" | "yes" => { state.consent = AnalyticsConsent::Enabled; state.prompted_at_ms = Some(now_ms()); state.updated_at_ms = now_ms(); let _ = save_state(&state); println!("Anonymous usage tracking enabled."); } "later" => { state.prompted_at_ms = Some(now_ms()); state.updated_at_ms = now_ms(); let _ = save_state(&state); println!("You can enable later with: f analytics enable"); } _ => { state.consent = AnalyticsConsent::Disabled; state.prompted_at_ms = Some(now_ms()); state.updated_at_ms = now_ms(); let _ = save_state(&state); println!("Anonymous usage tracking disabled."); } } } pub fn record_command_result(capture: &CommandCapture, duration: Duration, result: &Result<()>) { let runtime_cfg = runtime_config(); let mut state = match load_or_init_state() { Ok(state) => state, Err(_) => return, }; if !should_capture(&state, &runtime_cfg) { return; } if state.install_id.is_empty() { state.install_id = Uuid::new_v4().to_string(); let _ = save_state(&state); } let event = json!({ "type": "flow.command.v1", "schema_version": 1, "event_id": Uuid::new_v4().to_string(), "name": capture.command_path, "ok": result.is_ok(), "at": capture.at_ms, "source": "flow-cli", "payload": { "anon_user_id": rotating_anon_user_id(&state, capture.at_ms), "command_path": capture.command_path, "success": result.is_ok(), "exit_code": Option::::None, "duration_ms": duration.as_millis().min(u64::MAX as u128) as u64, "flags_used": capture.flags_used, "flow_version": capture.flow_version, "os": capture.os, "arch": capture.arch, "interactive": capture.interactive, "ci": capture.ci, "project_fingerprint": project_fingerprint(&state.local_secret, capture.at_ms), } }); if append_event_to_queue(&event).is_err() { return; } spawn_flush_worker(runtime_cfg.endpoint); } pub fn status() -> Result { let state = load_or_init_state()?; let runtime_cfg = runtime_config(); let queue_path = queue_path(); let queued_events = count_queue_lines()?; Ok(AnalyticsStatus { consent: state.consent, effective_enabled: should_capture(&state, &runtime_cfg), install_id: state.install_id, endpoint: runtime_cfg.endpoint, queue_path, queued_events, }) } pub fn set_consent(consent: AnalyticsConsent) -> Result<()> { let mut state = load_or_init_state()?; state.consent = consent; state.updated_at_ms = now_ms(); if state.prompted_at_ms.is_none() { state.prompted_at_ms = Some(now_ms()); } save_state(&state) } pub fn export_queue() -> Result { let path = queue_path(); if !path.exists() { return Ok(String::new()); } fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display())) } pub fn purge_queue() -> Result<()> { let path = queue_path(); if path.exists() { fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?; } Ok(()) } fn command_path(raw_args: &[String]) -> String { let known_commands: HashSet<&'static str> = HashSet::from([ "search", "global", "hub", "init", "shell-init", "shell", "new", "home", "archive", "doctor", "health", "tasks", "run", "last-cmd", "last-cmd-full", "fish-last", "fish-last-full", "fish-install", "rerun", "ps", "kill", "logs", "trace", "projects", "sessions", "active", "server", "web", "match", "ask", "commit", "commit-queue", "pr", "gitignore", "review", "commitSimple", "commitWithCheck", "undo", "fix", "fixup", "changes", "diff", "hash", "daemon", "supervisor", "ai", "codex", "claude", "env", "otp", "auth", "services", "push", "ssh", "macos", "todo", "ext", "deps", "skills", "db", "tools", "notify", "commits", "setup", "agents", "hive", "sync", "checkout", "switch", "info", "upstream", "deploy", "prod", "publish", "clone", "repos", "code", "migrate", "parallel", "docs", "upgrade", "latest", "release", "install", "registry", "proxy", "analytics", ]); let mut parts = Vec::new(); for arg in raw_args.iter().skip(1) { if arg == "--" { break; } if arg.starts_with('-') { continue; } parts.push(arg.as_str()); } if parts.is_empty() { return "palette".to_string(); } let first = parts[0]; if !known_commands.contains(first) { return "task-shortcut".to_string(); } let command_with_actions: HashSet<&'static str> = HashSet::from([ "skills", "analytics", "ai", "trace", "proxy", "daemon", "env", "services", "todo", "ext", "deps", "tools", "agents", "hive", "sync", "release", "install", "registry", ]); if command_with_actions.contains(first) && parts.len() > 1 { let second = parts[1]; if second != "force" && second != "review" && !second.starts_with('-') { return format!("{}.{}", first, second); } } first.to_string() } fn extract_flags(raw_args: &[String]) -> Vec { let mut set = BTreeSet::new(); for arg in raw_args.iter().skip(1) { if arg == "--" { break; } if let Some(rest) = arg.strip_prefix("--") { if rest.is_empty() { continue; } let name = rest.split('=').next().unwrap_or_default().trim(); if !name.is_empty() { set.insert(name.to_string()); } continue; } if let Some(rest) = arg.strip_prefix('-') { if rest.is_empty() || rest.starts_with('-') { continue; } for ch in rest.chars() { if ch.is_ascii_alphanumeric() { set.insert(ch.to_string()); } } } } set.into_iter().collect() } fn rotating_anon_user_id(state: &AnalyticsState, at_ms: u64) -> Option { if state.local_secret.is_empty() || state.install_id.is_empty() { return None; } let mut mac = HmacSha256::new_from_slice(state.local_secret.as_bytes()).ok()?; mac.update(state.install_id.as_bytes()); mac.update(b":"); mac.update(rotation_bucket(at_ms).to_string().as_bytes()); let bytes = mac.finalize().into_bytes(); let full = hex::encode(bytes); Some(full.chars().take(24).collect()) } fn project_fingerprint(secret: &str, at_ms: u64) -> Option { if secret.is_empty() { return None; } let cwd = std::env::current_dir().ok()?; let canonical = cwd.canonicalize().unwrap_or(cwd); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?; mac.update(canonical.to_string_lossy().as_bytes()); mac.update(b":"); mac.update(rotation_bucket(at_ms).to_string().as_bytes()); let bytes = mac.finalize().into_bytes(); let full = hex::encode(bytes); Some(full.chars().take(16).collect()) } fn rotation_bucket(at_ms: u64) -> u64 { let window = ANON_ROTATION_DAYS .saturating_mul(24) .saturating_mul(60) .saturating_mul(60) .saturating_mul(1000); if window == 0 { return 0; } at_ms / window } fn should_capture(state: &AnalyticsState, runtime: &AnalyticsRuntimeConfig) -> bool { if env_flag("FLOW_ANALYTICS_DISABLE") { return false; } if env_flag("FLOW_ANALYTICS_FORCE") { return true; } if let Some(enabled) = runtime.enabled { return enabled; } state.consent == AnalyticsConsent::Enabled } fn runtime_config() -> AnalyticsRuntimeConfig { let mut enabled = None; let mut endpoint = DEFAULT_ANALYTICS_ENDPOINT.to_string(); let mut sample_rate = 1.0f32; if let Some(cfg) = load_project_analytics_config() { enabled = cfg.enabled; if let Some(v) = cfg.endpoint { if !v.trim().is_empty() { endpoint = v.trim().to_string(); } } if let Some(v) = cfg.sample_rate { sample_rate = v.clamp(0.0, 1.0); } } if sample_rate < 1.0 { let random = (now_ms() % 10_000) as f32 / 10_000.0; if random > sample_rate { enabled = Some(false); } } AnalyticsRuntimeConfig { enabled, endpoint, sample_rate, } } fn load_project_analytics_config() -> Option { let cwd = std::env::current_dir().ok()?; if let Some(candidate) = crate::project_snapshot::find_flow_toml_upwards(&cwd) { if let Some(analytics) = load_minimal_analytics_config(&candidate, &mut Vec::new()) { return Some(analytics); } let cfg = config::load_or_default(&candidate); return cfg.analytics; } let global = config::default_config_path(); if global.exists() { if let Some(analytics) = load_minimal_analytics_config(&global, &mut Vec::new()) { return Some(analytics); } return config::load_or_default(global).analytics; } None } fn load_minimal_analytics_config( path: &Path, visited: &mut Vec, ) -> Option { let canonical = path.canonicalize().ok()?; if visited.contains(&canonical) { return None; } visited.push(canonical.clone()); let contents = fs::read_to_string(&canonical).ok()?; let parsed: AnalyticsConfigProbe = toml::from_str(&contents).ok()?; if let Some(analytics) = parsed.analytics { visited.pop(); return Some(analytics); } for include in parsed.command_files { let include_path = config::resolve_include_path(&canonical, &include.path); if let Some(analytics) = load_minimal_analytics_config(&include_path, visited) { visited.pop(); return Some(analytics); } } visited.pop(); None } fn load_or_init_state() -> Result { let path = state_path(); if path.exists() { let contents = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let mut state: AnalyticsState = toml::from_str(&contents) .with_context(|| format!("failed to parse {}", path.display()))?; if state.install_id.trim().is_empty() { state.install_id = Uuid::new_v4().to_string(); state.updated_at_ms = now_ms(); save_state(&state)?; } if state.local_secret.trim().is_empty() { state.local_secret = Uuid::new_v4().to_string(); state.updated_at_ms = now_ms(); save_state(&state)?; } return Ok(state); } let state = AnalyticsState::new_unknown(); save_state(&state)?; Ok(state) } fn save_state(state: &AnalyticsState) -> Result<()> { let path = state_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let payload = toml::to_string_pretty(state).context("failed to encode analytics state")?; fs::write(&path, payload).with_context(|| format!("failed to write {}", path.display())) } fn state_path() -> PathBuf { config::global_config_dir().join(STATE_FILE_NAME) } fn queue_path() -> PathBuf { config::global_state_dir().join(QUEUE_FILE_NAME) } fn append_event_to_queue(event: &Value) -> Result<()> { let path = queue_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let mut file = OpenOptions::new() .create(true) .append(true) .open(&path) .with_context(|| format!("failed to open {}", path.display()))?; let line = serde_json::to_string(event).context("failed to encode analytics event")?; writeln!(file, "{line}").with_context(|| format!("failed to append {}", path.display()))?; enforce_queue_limit(&path)?; Ok(()) } fn enforce_queue_limit(path: &PathBuf) -> Result<()> { let metadata = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; if metadata.len() as usize <= MAX_QUEUE_BYTES { return Ok(()); } let lines = read_queue_lines()?; if lines.is_empty() { return Ok(()); } let keep = lines.len().saturating_sub(lines.len() / 4).max(1); let trimmed: String = lines .into_iter() .rev() .take(keep) .collect::>() .into_iter() .rev() .map(|line| format!("{line}\n")) .collect(); fs::write(path, trimmed).with_context(|| format!("failed to trim {}", path.display())) } fn spawn_flush_worker(endpoint: String) { if FLUSH_IN_PROGRESS .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { return; } std::thread::spawn(move || { let _ = flush_queue(&endpoint); FLUSH_IN_PROGRESS.store(false, Ordering::SeqCst); }); } fn flush_queue(endpoint: &str) -> Result<()> { let path = queue_path(); if !path.exists() { return Ok(()); } let lines = read_queue_lines()?; if lines.is_empty() { return Ok(()); } let client = http_client::blocking_with_timeout(Duration::from_millis(500)) .context("failed to build analytics HTTP client")?; let mut sent = 0usize; for line in lines.iter().take(MAX_BATCH_SIZE) { let value: Value = match serde_json::from_str(line) { Ok(v) => v, Err(_) => { sent += 1; continue; } }; let response = client .post(endpoint) .header("content-type", "application/json") .json(&value) .send(); match response { Ok(resp) if resp.status().is_success() => { sent += 1; } _ => break, } } if sent == 0 { return Ok(()); } let remaining: String = lines .into_iter() .skip(sent) .map(|line| format!("{line}\n")) .collect(); fs::write(&path, remaining).with_context(|| format!("failed to rewrite {}", path.display())) } fn read_queue_lines() -> Result> { let path = queue_path(); if !path.exists() { return Ok(Vec::new()); } let file = std::fs::File::open(&path).with_context(|| format!("failed to read {}", path.display()))?; let reader = BufReader::new(file); let mut lines = Vec::new(); for line in reader.lines() { let line = line.with_context(|| format!("failed to read {}", path.display()))?; if !line.trim().is_empty() { lines.push(line); } } Ok(lines) } fn count_queue_lines() -> Result { let path = queue_path(); if !path.exists() { return Ok(0); } let file = std::fs::File::open(&path).with_context(|| format!("failed to read {}", path.display()))?; let reader = BufReader::new(file); let mut count = 0usize; for line in reader.lines() { let line = line.with_context(|| format!("failed to read {}", path.display()))?; if !line.trim().is_empty() { count += 1; } } Ok(count) } fn env_flag(name: &str) -> bool { std::env::var(name) .ok() .map(|value| { matches!( value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ) }) .unwrap_or(false) } fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64) .unwrap_or(0) } #[cfg(test)] mod tests { use std::fs; use tempfile::tempdir; use super::*; #[test] fn extracts_flags_without_values() { let args = vec![ "f".to_string(), "commit".to_string(), "--sync".to_string(), "-nt".to_string(), "--message=hello".to_string(), "arg".to_string(), ]; let flags = extract_flags(&args); assert!(flags.contains(&"sync".to_string())); assert!(flags.contains(&"n".to_string())); assert!(flags.contains(&"t".to_string())); assert!(flags.contains(&"message".to_string())); assert!(!flags.contains(&"hello".to_string())); } #[test] fn unknown_commands_map_to_task_shortcut() { let args = vec![ "f".to_string(), "dev".to_string(), "--port".to_string(), "3000".to_string(), ]; assert_eq!(command_path(&args), "task-shortcut"); } #[test] fn rotating_anon_id_is_deterministic_within_bucket() { let state = AnalyticsState { consent: AnalyticsConsent::Enabled, install_id: "install-123".to_string(), local_secret: "local-test-key".to_string(), // flow:secret:ignore prompted_at_ms: None, updated_at_ms: 0, }; let a = rotating_anon_user_id(&state, 1000).expect("anon id"); let b = rotating_anon_user_id(&state, 2000).expect("anon id"); assert_eq!(a, b); } #[test] fn rotating_anon_id_changes_after_rotation_window() { let state = AnalyticsState { consent: AnalyticsConsent::Enabled, install_id: "install-123".to_string(), local_secret: "local-test-key".to_string(), // flow:secret:ignore prompted_at_ms: None, updated_at_ms: 0, }; let window = ANON_ROTATION_DAYS * 24 * 60 * 60 * 1000; let a = rotating_anon_user_id(&state, 1000).expect("anon id"); let b = rotating_anon_user_id(&state, 1000 + window).expect("anon id"); assert_ne!(a, b); } #[test] fn minimal_analytics_probe_follows_includes() { let dir = tempdir().expect("tempdir"); let root = dir.path().join("repo"); fs::create_dir_all(&root).expect("repo dir"); fs::write( root.join("flow.toml"), r#" [[commands]] path = "commands.toml" "#, ) .expect("write root flow"); fs::write( root.join("commands.toml"), r#" [analytics] enabled = true endpoint = "https://example.test/telemetry" sample_rate = 0.25 "#, ) .expect("write commands flow"); let analytics = load_minimal_analytics_config(&root.join("flow.toml"), &mut Vec::new()) .expect("analytics"); assert_eq!(analytics.enabled, Some(true)); assert_eq!( analytics.endpoint.as_deref(), Some("https://example.test/telemetry") ); assert_eq!(analytics.sample_rate, Some(0.25)); } } ================================================ FILE: src/vcs.rs ================================================ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; pub fn ensure_jj_installed() -> Result<()> { let status = Command::new("jj") .arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .context("failed to run jj --version")?; if !status.success() { bail!("jj is required but not available on PATH"); } Ok(()) } pub fn ensure_jj_repo() -> Result { let cwd = std::env::current_dir().context("failed to read current directory")?; ensure_jj_repo_in(&cwd) } pub fn ensure_jj_repo_in(path: &Path) -> Result { ensure_jj_installed()?; if let Ok(root) = try_jj_root(path) { return Ok(root); } let git_dir = path.join(".git"); if git_dir.exists() { let status = Command::new("jj") .current_dir(path) .args(["git", "init", "--colocate"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .context("failed to run jj git init --colocate")?; if status.success() { if let Ok(root) = try_jj_root(path) { return Ok(root); } } } bail!( "This repo is not a jj workspace. Run `jj git init --colocate` in {} and retry.", path.display() ); } pub fn jj_root_if_exists(path: &Path) -> Option { let output = Command::new("jj") .current_dir(path) .arg("root") .output() .ok()?; if !output.status.success() { return None; } let root = String::from_utf8_lossy(&output.stdout).trim().to_string(); if root.is_empty() { None } else { Some(PathBuf::from(root)) } } fn try_jj_root(path: &Path) -> Result { let output = Command::new("jj") .current_dir(path) .arg("root") .output() .context("failed to run jj root")?; if !output.status.success() { bail!("jj root failed"); } Ok(PathBuf::from( String::from_utf8_lossy(&output.stdout).trim(), )) } ================================================ FILE: src/watchers.rs ================================================ use std::{ path::{Path, PathBuf}, process::Command, sync::mpsc::{self, Receiver, Sender}, thread, time::{Duration, Instant}, }; use anyhow::{Context, Result}; use notify::RecursiveMode; use notify_debouncer_mini::{DebouncedEvent, new_debouncer}; use crate::config::{WatcherConfig, WatcherDriver, expand_path}; pub struct WatchManager { handles: Vec, } impl WatchManager { pub fn start(configs: &[WatcherConfig]) -> Result> { if configs.is_empty() { return Ok(None); } let mut handles = Vec::new(); for cfg in configs.iter().cloned() { match WatcherHandle::spawn(cfg) { Ok(handle) => handles.push(handle), Err(err) => { tracing::error!(?err, "failed to start watcher"); } } } if handles.is_empty() { Ok(None) } else { Ok(Some(Self { handles })) } } } impl Drop for WatchManager { fn drop(&mut self) { self.handles.clear(); } } pub struct WatcherHandle { shutdown: Option>, join: Option>, } impl WatcherHandle { fn spawn(cfg: WatcherConfig) -> Result { match cfg.driver { WatcherDriver::Shell => Self::spawn_shell(cfg), WatcherDriver::Poltergeist => Self::spawn_poltergeist(cfg), } } fn spawn_shell(cfg: WatcherConfig) -> Result { let (shutdown_tx, shutdown_rx) = mpsc::channel(); let handle = thread::spawn(move || { if let Err(err) = run_shell_watcher(cfg, shutdown_rx) { tracing::error!(?err, "watcher exited with error"); } }); Ok(Self { shutdown: Some(shutdown_tx), join: Some(handle), }) } fn spawn_poltergeist(cfg: WatcherConfig) -> Result { let (shutdown_tx, shutdown_rx) = mpsc::channel(); let handle = thread::spawn(move || { if let Err(err) = run_poltergeist_watcher(cfg, shutdown_rx) { tracing::error!(?err, "poltergeist watcher exited with error"); } }); Ok(Self { shutdown: Some(shutdown_tx), join: Some(handle), }) } } impl Drop for WatcherHandle { fn drop(&mut self) { if let Some(tx) = self.shutdown.take() { let _ = tx.send(()); } if let Some(handle) = self.join.take() { let _ = handle.join(); } } } fn run_shell_watcher(cfg: WatcherConfig, shutdown: Receiver<()>) -> Result<()> { let watch_path = expand_path(&cfg.path); if !watch_path.exists() { anyhow::bail!( "watch path {} does not exist (watcher {})", watch_path.display(), cfg.name ); } let workdir = if watch_path.is_dir() { watch_path.clone() } else { watch_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")) }; if cfg.run_on_start { run_command(&cfg, &workdir); } let debounce = Duration::from_millis(cfg.debounce_ms.max(50)); let (event_tx, event_rx) = mpsc::channel(); let mut debouncer = new_debouncer(debounce, event_tx).context("failed to initialize file watcher")?; debouncer .watcher() .watch(&watch_path, RecursiveMode::Recursive) .with_context(|| format!("failed to watch path {}", watch_path.display()))?; tracing::info!( name = cfg.name, path = %watch_path.display(), "watcher started" ); loop { if shutdown.try_recv().is_ok() { break; } match event_rx.recv_timeout(Duration::from_millis(200)) { Ok(Ok(events)) => { if matches_filter(&events, cfg.filter.as_deref()) { run_command(&cfg, &workdir); } } Ok(Err(err)) => { tracing::warn!(?err, watcher = cfg.name, "watcher error"); } Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => break, } } tracing::info!(name = cfg.name, "watcher stopped"); Ok(()) } fn matches_filter(events: &[DebouncedEvent], filter: Option<&str>) -> bool { match filter { None => true, Some(target) => events.iter().any(|event| { event .path .file_name() .and_then(|name| name.to_str()) .map(|name| name == target || name.contains(target)) .unwrap_or(false) }), } } fn run_poltergeist_watcher(cfg: WatcherConfig, shutdown: Receiver<()>) -> Result<()> { let watch_path = expand_path(&cfg.path); if !watch_path.exists() { anyhow::bail!( "watch path {} does not exist (watcher {})", watch_path.display(), cfg.name ); } let workdir = if watch_path.is_dir() { watch_path.clone() } else { watch_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")) }; let poltergeist = cfg.poltergeist.clone().unwrap_or_default(); tracing::info!( name = cfg.name, path = %workdir.display(), mode = %poltergeist.mode.as_subcommand(), binary = %poltergeist.binary, "starting poltergeist watcher" ); let mut command = Command::new(&poltergeist.binary); command.arg(poltergeist.mode.as_subcommand()); if !poltergeist.args.is_empty() { command.args(&poltergeist.args); } command.current_dir(&workdir); command.envs(cfg.env.iter().map(|(k, v)| (k, v))); command.stdout(std::process::Stdio::inherit()); command.stderr(std::process::Stdio::inherit()); let mut child = command .spawn() .with_context(|| format!("failed to launch poltergeist for {}", cfg.name))?; loop { if shutdown.try_recv().is_ok() { tracing::info!(name = cfg.name, "stopping poltergeist watcher"); if let Err(err) = child.kill() { tracing::warn!( ?err, watcher = cfg.name, "failed to kill poltergeist process" ); } let _ = child.wait(); break; } match child.try_wait() { Ok(Some(status)) => { if status.success() { tracing::info!(name = cfg.name, ?status, "poltergeist watcher exited"); } else { tracing::warn!( name = cfg.name, ?status, "poltergeist watcher exited with error" ); } break; } Ok(None) => { thread::sleep(Duration::from_millis(500)); } Err(err) => { tracing::error!( ?err, name = cfg.name, "failed to query poltergeist watcher status" ); break; } } } Ok(()) } fn run_command(cfg: &WatcherConfig, workdir: &Path) { let Some(command) = cfg .command .as_deref() .map(str::trim) .filter(|cmd| !cmd.is_empty()) else { tracing::warn!(name = cfg.name, "watcher missing command; skipping"); return; }; tracing::info!( name = cfg.name, command = command, "running watcher command" ); let start = Instant::now(); let mut cmd = Command::new("/bin/sh"); cmd.arg("-c").arg(command).current_dir(workdir); cmd.envs(cfg.env.iter().map(|(k, v)| (k, v))); cmd.stdout(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::piped()); match cmd.spawn() { Ok(mut child) => { let _ = child.wait(); tracing::info!(name = cfg.name, ?workdir, elapsed = ?start.elapsed(), "watcher command finished"); } Err(err) => { tracing::error!(?err, name = cfg.name, "failed to execute watcher command"); } } } ================================================ FILE: src/web.rs ================================================ use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::time::SystemTime; use anyhow::{Context, Result, bail}; use axum::{ Router, extract::State, http::{StatusCode, Uri}, response::{Html, IntoResponse, Json, Response}, routing::get, }; use serde::Serialize; use tokio::runtime::Runtime; use which::which; use crate::ai; use crate::cli::WebOpts; #[derive(Clone)] struct WebState { project_root: PathBuf, web_root: Option, fallback_index: Option, } #[derive(Serialize)] struct ProjectsResponse { projects: Vec, } #[derive(Serialize)] struct AiTreeResponse { entries: Vec, } #[derive(Serialize)] struct SessionsResponse { sessions: Vec, } #[derive(Serialize)] struct ProjectCard { name: String, path: String, path_url: String, summary: Option, openapi: Option, ai_entries: Vec, status: String, } #[derive(Serialize)] struct OpenApiSpec { path: String, url: String, format: String, } #[derive(Serialize)] struct AiEntry { path: String, kind: String, } pub fn run(opts: WebOpts) -> Result<()> { let project_root = std::env::current_dir()?; ensure_web_ui(&project_root)?; build_web_ui(&project_root)?; let (web_root, fallback_index) = resolve_web_root(&project_root); let host = opts.host; let port = opts.port; let addr: SocketAddr = format!("{host}:{port}") .parse() .context("invalid host:port")?; let state = WebState { project_root: project_root.clone(), web_root, fallback_index, }; let rt = Runtime::new().context("failed to create tokio runtime")?; rt.block_on(async move { let app = Router::new() .route("/api/projects", get(projects)) .route("/api/ai", get(ai_tree)) .route("/api/sessions", get(sessions)) .route("/api/openapi", get(openapi)) .route("/", get(index)) .fallback(fallback) .with_state(state); let listener = tokio::net::TcpListener::bind(addr) .await .context("failed to bind web server")?; let url = format!("http://{host}:{port}"); open_in_browser(&url)?; println!("Flow web running at {url}"); axum::serve(listener, app) .await .context("web server error")?; Ok(()) }) } async fn index(State(state): State) -> Result, (StatusCode, String)> { if let Some(html) = &state.fallback_index { return Ok(Html(html.clone())); } let web_root = state .web_root .as_ref() .ok_or((StatusCode::NOT_FOUND, "missing web root".to_string()))?; let index_path = web_root.join("index.html"); let html = fs::read_to_string(&index_path) .map_err(|err| (StatusCode::NOT_FOUND, format!("missing index.html: {err}")))?; Ok(Html(html)) } async fn fallback( State(state): State, uri: Uri, ) -> Result { let Some(web_root) = &state.web_root else { return index(State(state)).await.map(|html| html.into_response()); }; let path = uri.path().trim_start_matches('/'); if Path::new(path) .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err((StatusCode::NOT_FOUND, "not found".to_string())); } let file_path = web_root.join(path); if file_path.is_file() { let contents = fs::read(&file_path).map_err(|err| (StatusCode::NOT_FOUND, err.to_string()))?; let content_type = content_type_for_path(&file_path); return Ok(( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], contents, ) .into_response()); } index(State(state)).await.map(|html| html.into_response()) } async fn projects(State(state): State) -> Result, StatusCode> { let project = build_project_card(&state.project_root); Ok(Json(ProjectsResponse { projects: vec![project], })) } async fn ai_tree(State(state): State) -> Result, StatusCode> { let entries = list_ai_tree_entries(&state.project_root.join(".ai")); Ok(Json(AiTreeResponse { entries })) } async fn sessions(State(state): State) -> Result, StatusCode> { let sessions = ai::get_sessions_for_web(&state.project_root) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(SessionsResponse { sessions })) } async fn openapi(State(state): State) -> Result { let Some((path, format)) = find_openapi_spec(&state.project_root) else { return Err(StatusCode::NOT_FOUND); }; let contents = fs::read(&path).map_err(|_| StatusCode::NOT_FOUND)?; let content_type = if format == "json" { "application/json" } else { "application/yaml" }; Ok(([(axum::http::header::CONTENT_TYPE, content_type)], contents)) } fn build_project_card(project_root: &Path) -> ProjectCard { let name = project_root .file_name() .and_then(|s| s.to_str()) .unwrap_or("project") .to_string(); let summary = read_first_line(project_root.join("readme.md")) .or_else(|| read_first_line(project_root.join("README.md"))); let openapi = find_openapi_spec(project_root).map(|(path, format)| { let rel = path.strip_prefix(project_root).unwrap_or(&path); OpenApiSpec { path: rel.to_string_lossy().to_string(), url: "/api/openapi".to_string(), format, } }); let ai_entries = list_ai_top_entries(&project_root.join(".ai")); let has_openapi = openapi.is_some(); ProjectCard { name, path: project_root.display().to_string(), path_url: format!("file://{}", project_root.display()), summary, openapi, ai_entries, status: if has_openapi { "OpenAPI" } else { "Ready" }.to_string(), } } fn read_first_line(path: PathBuf) -> Option { let content = fs::read_to_string(path).ok()?; for line in content.lines() { let trimmed = line.trim(); if trimmed.is_empty() { continue; } return Some(trimmed.to_string()); } None } fn find_openapi_spec(project_root: &Path) -> Option<(PathBuf, String)> { let candidates = [ (project_root.join("openapi.json"), "json"), (project_root.join("openapi.yaml"), "yaml"), (project_root.join("openapi.yml"), "yaml"), (project_root.join("spec/openapi.json"), "json"), (project_root.join("spec/openapi.yaml"), "yaml"), (project_root.join("spec/openapi.yml"), "yaml"), (project_root.join("docs/openapi.json"), "json"), (project_root.join("docs/openapi.yaml"), "yaml"), (project_root.join("docs/openapi.yml"), "yaml"), (project_root.join("openapi/openapi.json"), "json"), (project_root.join("openapi/openapi.yaml"), "yaml"), (project_root.join("openapi/openapi.yml"), "yaml"), (project_root.join(".ai/openapi.json"), "json"), (project_root.join(".ai/openapi.yaml"), "yaml"), (project_root.join(".ai/openapi.yml"), "yaml"), ]; candidates .into_iter() .find(|(path, _)| path.exists()) .map(|(path, format)| (path, format.to_string())) } fn list_ai_top_entries(ai_root: &Path) -> Vec { let mut entries = Vec::new(); let dir = match fs::read_dir(ai_root) { Ok(dir) => dir, Err(_) => return entries, }; for entry in dir.flatten() { let path = entry.path(); let name = match path.file_name().and_then(|s| s.to_str()) { Some(name) => name.to_string(), None => continue, }; let kind = if path.is_dir() { "dir" } else { "file" }; entries.push(AiEntry { path: name, kind: kind.to_string(), }); } entries.sort_by(|a, b| a.path.cmp(&b.path)); entries } fn list_ai_tree_entries(ai_root: &Path) -> Vec { let mut entries = Vec::new(); if !ai_root.exists() { return entries; } let mut stack = vec![ai_root.to_path_buf()]; while let Some(dir) = stack.pop() { let dir_entries = match fs::read_dir(&dir) { Ok(dir_entries) => dir_entries, Err(_) => continue, }; for entry in dir_entries.flatten() { let path = entry.path(); let rel = match path.strip_prefix(ai_root) { Ok(rel) if !rel.as_os_str().is_empty() => rel, _ => continue, }; let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(_) => continue, }; let file_type = metadata.file_type(); let kind = if file_type.is_symlink() { "symlink" } else if file_type.is_dir() { "dir" } else { "file" }; entries.push(AiEntry { path: rel.to_string_lossy().to_string(), kind: kind.to_string(), }); if file_type.is_dir() && !file_type.is_symlink() { stack.push(path); } } } entries.sort_by(|a, b| a.path.cmp(&b.path)); entries } fn content_type_for_path(path: &Path) -> &'static str { let ext = path .extension() .and_then(|ext| ext.to_str()) .unwrap_or("") .to_ascii_lowercase(); match ext.as_str() { "html" => "text/html; charset=utf-8", "css" => "text/css; charset=utf-8", "js" | "mjs" => "text/javascript; charset=utf-8", "json" | "map" => "application/json", "svg" => "image/svg+xml", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "ico" => "image/x-icon", "webp" => "image/webp", "wasm" => "application/wasm", "woff" => "font/woff", "woff2" => "font/woff2", "ttf" => "font/ttf", _ => "application/octet-stream", } } fn build_web_ui(project_root: &Path) -> Result<()> { let web_root = project_root.join(".ai").join("web"); let package_json = web_root.join("package.json"); if !package_json.exists() { return Ok(()); } if which("bun").is_err() { bail!("bun is required to build .ai/web (install bun or remove .ai/web/package.json)"); } let node_modules = web_root.join("node_modules"); let install_stamp = node_modules.join(".flow-web-install"); if needs_install( &node_modules, &package_json, &web_root.join("bun.lock"), &install_stamp, )? { run_command("bun", &["install"], &web_root).context("bun install failed for .ai/web")?; write_install_stamp(&install_stamp)?; } run_command("bun", &["run", "build"], &web_root).context("bun run build failed for .ai/web")?; Ok(()) } fn needs_install( node_modules: &Path, package_json: &Path, bun_lock: &Path, install_stamp: &Path, ) -> Result { if !node_modules.exists() { return Ok(true); } if !install_stamp.exists() { return Ok(true); } if is_newer(package_json, install_stamp)? { return Ok(true); } if bun_lock.exists() && is_newer(bun_lock, install_stamp)? { return Ok(true); } Ok(false) } fn is_newer(path: &Path, stamp: &Path) -> Result { let path_time = file_modified(path)?; let stamp_time = file_modified(stamp)?; Ok(path_time > stamp_time) } fn file_modified(path: &Path) -> Result { let metadata = fs::metadata(path)?; Ok(metadata.modified()?) } fn write_install_stamp(path: &Path) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(path, b"installed")?; Ok(()) } fn run_command(command: &str, args: &[&str], cwd: &Path) -> Result<()> { let status = std::process::Command::new(command) .args(args) .current_dir(cwd) .status() .with_context(|| format!("failed to spawn {}", command))?; if status.success() { Ok(()) } else { bail!("{} {:?} exited with {}", command, args, status) } } #[cfg(target_os = "macos")] fn open_in_browser(url: &str) -> Result<()> { std::process::Command::new("open").arg(url).status()?; Ok(()) } #[cfg(target_os = "linux")] fn open_in_browser(url: &str) -> Result<()> { std::process::Command::new("xdg-open").arg(url).status()?; Ok(()) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn open_in_browser(url: &str) -> Result<()> { println!("Open this URL in your browser: {url}"); Ok(()) } pub fn ensure_web_ui(project_root: &Path) -> Result<()> { let web_root = project_root.join(".ai").join("web"); if !web_root.exists() { fs::create_dir_all(&web_root)?; } let index_path = web_root.join("index.html"); let has_vite_source = web_root.join("package.json").exists() && web_root.join("src").exists(); if !index_path.exists() && !has_vite_source { fs::write(&index_path, default_web_template())?; } Ok(()) } fn resolve_web_root(project_root: &Path) -> (Option, Option) { let web_root = project_root.join(".ai").join("web"); let dist_root = web_root.join("dist"); let dist_index = dist_root.join("index.html"); if dist_index.exists() { return (Some(dist_root), None); } let has_vite_source = web_root.join("package.json").exists() && web_root.join("src").exists(); if has_vite_source { return (None, Some(default_web_template().to_string())); } let root_index = web_root.join("index.html"); if root_index.exists() { return (Some(web_root), None); } (None, Some(default_web_template().to_string())) } fn default_web_template() -> &'static str { r#" Flow Web

Flow Web UI not built

Build your Vite app to .ai/web/dist and refresh. Example: vite build

API endpoints are live at: /api/projects, /api/ai, /api/openapi

"# } ================================================ FILE: src/workflow.rs ================================================ use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; use chrono::{SecondsFormat, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use crate::projects::{self, ProjectEntry}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WorkflowOverview { pub generated_at: String, pub repos: Vec, pub errors: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WorkflowRepoSnapshot { pub id: String, pub name: String, pub vcs: String, pub repo_key: String, pub repo_root: String, pub repo_slug: Option, pub default_branch: Option, pub project_count: usize, pub workspace_count: usize, pub active_branch_count: usize, pub open_pr_count: usize, pub hidden_branch_count: usize, pub pr_error: Option, pub projects: Vec, pub workspaces: Vec, pub branches: Vec, pub error: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WorkflowProjectRef { pub name: String, pub project_root: String, pub repo_relative_path: String, pub workspace_name: Option, pub workspace_root: Option, pub current_branches: Vec, pub dirty: bool, pub conflict: bool, pub updated_ms: u128, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WorkflowWorkspaceSnapshot { pub name: String, pub root_path: Option, pub current_branches: Vec, pub dirty: bool, pub conflict: bool, pub target_commit: String, pub description: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WorkflowBranchSnapshot { pub name: String, pub head_sha: String, pub short_sha: String, pub subject: String, pub updated_at: Option, pub is_current: bool, pub is_active: bool, pub hidden: bool, pub workspace_names: Vec, pub dirty: bool, pub conflict: bool, pub tracking_remote: Option, pub tracked: bool, pub synced: bool, pub ahead_count: Option, pub behind_count: Option, pub upstream_sha: Option, pub compare_base_branch: Option, pub pull_request: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkflowPullRequestSummary { pub number: u64, pub title: String, pub url: String, pub state: String, pub is_draft: bool, pub base_ref_name: String, pub head_ref_name: String, pub updated_at: String, pub review_decision: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RepoVcs { Jj, Git, } impl RepoVcs { fn as_str(self) -> &'static str { match self { RepoVcs::Jj => "jj", RepoVcs::Git => "git", } } } #[derive(Debug, Clone)] struct ProjectBinding { project: ProjectEntry, vcs: RepoVcs, logical_key: String, repo_root: PathBuf, workspace_root: Option, workspace_name: Option, current_branches: Vec, dirty: bool, conflict: bool, } #[derive(Debug, Clone)] struct RepoSeed { vcs: RepoVcs, logical_key: String, repo_root: PathBuf, bindings: Vec, } #[derive(Debug, Clone)] struct WorkspaceState { name: String, target_commit: String, current_branches: Vec, dirty: bool, conflict: bool, description: String, } #[derive(Debug, Clone)] struct CommitMeta { head_sha: String, short_sha: String, subject: String, updated_at: Option, } #[derive(Debug, Clone)] struct JjBookmarkRow { name: String, remote: Option, tracked: bool, conflict: bool, present: bool, synced: bool, ahead_count: Option, behind_count: Option, target_sha: Option, } #[derive(Debug, Clone)] struct GitWorktreeState { name: String, path: PathBuf, branch: Option, dirty: bool, } #[derive(Debug, Clone)] struct GitBranchRow { name: String, head_sha: String, short_sha: String, updated_at: Option, subject: String, upstream: Option, ahead_count: Option, behind_count: Option, } #[derive(Clone)] struct CachedWorkflowOverview { captured_at: Instant, overview: WorkflowOverview, } static WORKFLOW_OVERVIEW_CACHE: OnceLock>> = OnceLock::new(); const WORKFLOW_OVERVIEW_TTL: Duration = Duration::from_secs(30); pub fn load_workflow_overview() -> Result { if let Some(cached) = workflow_cache() .lock() .expect("workflow cache mutex poisoned") .clone() .filter(|cached| cached.captured_at.elapsed() < WORKFLOW_OVERVIEW_TTL) { return Ok(cached.overview); } let projects = projects::list_projects()?; let mut repo_map: HashMap = HashMap::new(); let mut errors = Vec::new(); for project in projects { match detect_project_binding(&project) { Ok(Some(binding)) => { let key = binding.logical_key.clone(); repo_map .entry(key.clone()) .and_modify(|seed| seed.bindings.push(binding.clone())) .or_insert_with(|| RepoSeed { vcs: binding.vcs, logical_key: key, repo_root: binding.repo_root.clone(), bindings: vec![binding], }); } Ok(None) => errors.push(format!( "{}: no jj or git repo found at {}", project.name, project.project_root.display() )), Err(err) => errors.push(format!( "{}: failed to inspect {}: {err:#}", project.name, project.project_root.display() )), } } let mut repos = Vec::new(); for seed in repo_map.into_values() { let snapshot = match seed.vcs { RepoVcs::Jj => inspect_jj_repo(&seed), RepoVcs::Git => inspect_git_repo(&seed), }; match snapshot { Ok(repo) => repos.push(repo), Err(err) => { errors.push(format!( "{}: failed to build workflow snapshot: {err:#}", seed.repo_root.display() )); repos.push(repo_error_snapshot(&seed, err.to_string())); } } } repos.sort_by(|a, b| { b.active_branch_count .cmp(&a.active_branch_count) .then_with(|| b.open_pr_count.cmp(&a.open_pr_count)) .then_with(|| a.name.cmp(&b.name)) }); let overview = WorkflowOverview { generated_at: now_iso(), repos, errors, }; *workflow_cache().lock().expect("workflow cache mutex poisoned") = Some(CachedWorkflowOverview { captured_at: Instant::now(), overview: overview.clone(), }); Ok(overview) } fn workflow_cache() -> &'static Mutex> { WORKFLOW_OVERVIEW_CACHE.get_or_init(|| Mutex::new(None)) } fn detect_project_binding(project: &ProjectEntry) -> Result> { if let Ok(workspace_root) = capture_trimmed_in(&project.project_root, "jj", &["root"]) { let workspace_root = canonical_or_same(PathBuf::from(workspace_root)); if let Ok(repo_root) = resolve_jj_repo_store(&workspace_root) { let workspace_name = capture_trimmed_in( &project.project_root, "jj", &[ "log", "-r", "@", "-n", "1", "--no-graph", "-T", "working_copies.map(|w| w.name()).join(\",\") ++ \"\\n\"", ], ) .ok() .and_then(|value| first_non_empty_csv(&value)); let current_branches = capture_trimmed_in( &project.project_root, "jj", &[ "log", "-r", "@-", "-n", "1", "--no-graph", "-T", "local_bookmarks.map(|b| b.name()).join(\",\") ++ \"\\n\"", ], ) .map(|value| split_csv(&value)) .unwrap_or_default(); let workspace_state = capture_trimmed_in( &project.project_root, "jj", &[ "log", "-r", "@", "-n", "1", "--no-graph", "-T", "empty ++ \"\\t\" ++ conflict ++ \"\\n\"", ], ) .ok(); let (dirty, conflict) = parse_dirty_conflict_state(workspace_state.as_deref()); return Ok(Some(ProjectBinding { project: project.clone(), vcs: RepoVcs::Jj, logical_key: format!("jj:{}", repo_root.display()), repo_root, workspace_root: Some(workspace_root), workspace_name, current_branches, dirty, conflict, })); } } if let Ok(repo_root) = capture_trimmed_in( &project.project_root, "git", &["rev-parse", "--show-toplevel"], ) { let repo_root = canonical_or_same(PathBuf::from(repo_root)); let common_dir = capture_trimmed_in( &project.project_root, "git", &["rev-parse", "--git-common-dir"], )?; let common_dir = resolve_path(&project.project_root, common_dir.trim()); let common_dir = canonical_or_same(common_dir); let current_branch = capture_trimmed_in( &project.project_root, "git", &["branch", "--show-current"], ) .ok() .into_iter() .flat_map(|value| split_csv(&value)) .collect::>(); let status = capture_trimmed_in(&project.project_root, "git", &["status", "--porcelain"]) .unwrap_or_default(); let dirty = !status.trim().is_empty(); let conflict = status.lines().any(git_status_line_has_conflict); return Ok(Some(ProjectBinding { project: project.clone(), vcs: RepoVcs::Git, logical_key: format!("git:{}", common_dir.display()), repo_root, workspace_root: None, workspace_name: None, current_branches: current_branch, dirty, conflict, })); } Ok(None) } fn inspect_jj_repo(seed: &RepoSeed) -> Result { let root = preferred_repo_root(seed); let repo_slug = jj_repo_slug(&root); let (pr_map, pr_error) = fetch_open_prs(repo_slug.as_deref()); let workspaces = jj_workspace_states(&root)?; let bookmark_rows = jj_bookmark_rows(&root)?; let commit_meta = jj_commit_meta_by_bookmark(&root)?; let default_branch = infer_default_branch_jj(&bookmark_rows, pr_map.values()); let workspace_roots = seed .bindings .iter() .filter_map(|binding| { binding .workspace_name .as_ref() .zip(binding.workspace_root.as_ref()) .map(|(name, root)| (name.clone(), root.clone())) }) .collect::>(); let mut grouped = BTreeMap::>::new(); for row in bookmark_rows { grouped.entry(row.name.clone()).or_default().push(row); } let mut branches = Vec::new(); for (name, rows) in grouped { let Some(local) = rows.iter().find(|row| row.remote.is_none()).cloned() else { continue; }; let remotes = rows .iter() .filter(|row| row.remote.is_some()) .cloned() .collect::>(); let tracking = remotes .iter() .find(|row| row.remote.as_deref() == Some("origin")) .or_else(|| remotes.first()); let workspace_names = workspaces .iter() .filter(|workspace| workspace.current_branches.iter().any(|branch| branch == &name)) .map(|workspace| workspace.name.clone()) .collect::>(); let dirty = workspaces .iter() .any(|workspace| workspace.dirty && workspace.current_branches.iter().any(|branch| branch == &name)); let workspace_conflict = workspaces .iter() .any(|workspace| workspace.conflict && workspace.current_branches.iter().any(|branch| branch == &name)); let meta = commit_meta.get(&name).cloned().unwrap_or_else(|| CommitMeta { head_sha: local.target_sha.clone().unwrap_or_default(), short_sha: truncate_sha(local.target_sha.as_deref().unwrap_or("")), subject: String::new(), updated_at: None, }); let pull_request = pr_map.get(&name).cloned(); let compare_base_branch = pull_request .as_ref() .map(|pr| pr.base_ref_name.clone()) .or_else(|| default_branch.clone()); let is_current = !workspace_names.is_empty(); let hidden = is_hidden_branch(&name); let is_active = is_current || dirty || local.conflict || workspace_conflict || pull_request.is_some(); branches.push(WorkflowBranchSnapshot { name: name.clone(), head_sha: meta.head_sha, short_sha: meta.short_sha, subject: meta.subject, updated_at: meta.updated_at, is_current, is_active, hidden, workspace_names, dirty, conflict: local.conflict || workspace_conflict, tracking_remote: tracking.and_then(|row| row.remote.clone()), tracked: tracking.map(|row| row.tracked).unwrap_or(false), synced: local.synced, ahead_count: tracking.and_then(|row| row.ahead_count), behind_count: tracking.and_then(|row| row.behind_count), upstream_sha: tracking.and_then(|row| row.target_sha.clone()), compare_base_branch, pull_request, }); } sort_branches(&mut branches); let projects = seed .bindings .iter() .map(|binding| WorkflowProjectRef { name: binding.project.name.clone(), project_root: binding.project.project_root.display().to_string(), repo_relative_path: relative_display_path( binding.workspace_root.as_deref().unwrap_or(&binding.repo_root), &binding.project.project_root, ), workspace_name: binding.workspace_name.clone(), workspace_root: binding .workspace_root .as_ref() .map(|value| value.display().to_string()), current_branches: binding.current_branches.clone(), dirty: binding.dirty, conflict: binding.conflict, updated_ms: binding.project.updated_ms, }) .collect::>(); let workspaces = workspaces .into_iter() .map(|workspace| WorkflowWorkspaceSnapshot { name: workspace.name.clone(), root_path: workspace_roots .get(&workspace.name) .map(|value| value.display().to_string()), current_branches: workspace.current_branches, dirty: workspace.dirty, conflict: workspace.conflict, target_commit: workspace.target_commit, description: workspace.description, }) .collect::>(); let hidden_branch_count = branches.iter().filter(|branch| branch.hidden).count(); let active_branch_count = branches .iter() .filter(|branch| branch.is_active && !branch.hidden) .count(); let open_pr_count = branches .iter() .filter(|branch| { branch .pull_request .as_ref() .map(|pr| pr.state == "OPEN") .unwrap_or(false) }) .count(); Ok(WorkflowRepoSnapshot { id: seed.logical_key.clone(), name: display_repo_name(repo_slug.as_deref(), &root), vcs: seed.vcs.as_str().to_string(), repo_key: seed.logical_key.clone(), repo_root: root.display().to_string(), repo_slug, default_branch, project_count: projects.len(), workspace_count: workspaces.len(), active_branch_count, open_pr_count, hidden_branch_count, pr_error, projects, workspaces, branches, error: None, }) } fn inspect_git_repo(seed: &RepoSeed) -> Result { let root = seed.repo_root.clone(); let repo_slug = git_repo_slug(&root); let (pr_map, pr_error) = fetch_open_prs(repo_slug.as_deref()); let default_branch = git_default_branch(&root); let worktrees = git_worktree_states(&root)?; let mut branches = git_branch_rows(&root)? .into_iter() .map(|row| { let workspace_names = worktrees .iter() .filter(|workspace| workspace.branch.as_deref() == Some(row.name.as_str())) .map(|workspace| workspace.name.clone()) .collect::>(); let dirty = worktrees .iter() .any(|workspace| workspace.dirty && workspace.branch.as_deref() == Some(row.name.as_str())); let pull_request = pr_map.get(&row.name).cloned(); let compare_base_branch = pull_request .as_ref() .map(|pr| pr.base_ref_name.clone()) .or_else(|| default_branch.clone()); let hidden = is_hidden_branch(&row.name); let is_current = !workspace_names.is_empty(); let is_active = is_current || dirty || pull_request.is_some(); WorkflowBranchSnapshot { name: row.name.clone(), head_sha: row.head_sha.clone(), short_sha: row.short_sha.clone(), subject: row.subject.clone(), updated_at: row.updated_at.clone(), is_current, is_active, hidden, workspace_names, dirty, conflict: false, tracking_remote: row.upstream.clone(), tracked: row.upstream.is_some(), synced: row.ahead_count.unwrap_or(0) == 0 && row.behind_count.unwrap_or(0) == 0, ahead_count: row.ahead_count, behind_count: row.behind_count, upstream_sha: None, compare_base_branch, pull_request, } }) .collect::>(); sort_branches(&mut branches); let projects = seed .bindings .iter() .map(|binding| WorkflowProjectRef { name: binding.project.name.clone(), project_root: binding.project.project_root.display().to_string(), repo_relative_path: relative_display_path(&binding.repo_root, &binding.project.project_root), workspace_name: None, workspace_root: None, current_branches: binding.current_branches.clone(), dirty: binding.dirty, conflict: binding.conflict, updated_ms: binding.project.updated_ms, }) .collect::>(); let workspaces = worktrees .iter() .map(|workspace| WorkflowWorkspaceSnapshot { name: workspace.name.clone(), root_path: Some(workspace.path.display().to_string()), current_branches: workspace.branch.clone().into_iter().collect(), dirty: workspace.dirty, conflict: false, target_commit: String::new(), description: String::new(), }) .collect::>(); let hidden_branch_count = branches.iter().filter(|branch| branch.hidden).count(); let active_branch_count = branches .iter() .filter(|branch| branch.is_active && !branch.hidden) .count(); let open_pr_count = branches .iter() .filter(|branch| { branch .pull_request .as_ref() .map(|pr| pr.state == "OPEN") .unwrap_or(false) }) .count(); Ok(WorkflowRepoSnapshot { id: seed.logical_key.clone(), name: display_repo_name(repo_slug.as_deref(), &root), vcs: seed.vcs.as_str().to_string(), repo_key: seed.logical_key.clone(), repo_root: root.display().to_string(), repo_slug, default_branch, project_count: projects.len(), workspace_count: workspaces.len(), active_branch_count, open_pr_count, hidden_branch_count, pr_error, projects, workspaces, branches, error: None, }) } fn repo_error_snapshot(seed: &RepoSeed, error: String) -> WorkflowRepoSnapshot { WorkflowRepoSnapshot { id: seed.logical_key.clone(), name: display_repo_name(None, &seed.repo_root), vcs: seed.vcs.as_str().to_string(), repo_key: seed.logical_key.clone(), repo_root: seed.repo_root.display().to_string(), repo_slug: None, default_branch: None, project_count: seed.bindings.len(), workspace_count: 0, active_branch_count: 0, open_pr_count: 0, hidden_branch_count: 0, pr_error: None, projects: seed .bindings .iter() .map(|binding| WorkflowProjectRef { name: binding.project.name.clone(), project_root: binding.project.project_root.display().to_string(), repo_relative_path: ".".to_string(), workspace_name: binding.workspace_name.clone(), workspace_root: binding .workspace_root .as_ref() .map(|value| value.display().to_string()), current_branches: binding.current_branches.clone(), dirty: binding.dirty, conflict: binding.conflict, updated_ms: binding.project.updated_ms, }) .collect(), workspaces: Vec::new(), branches: Vec::new(), error: Some(error), } } fn preferred_repo_root(seed: &RepoSeed) -> PathBuf { let mut roots = seed .bindings .iter() .filter_map(|binding| binding.workspace_root.clone()) .collect::>(); if roots.is_empty() { return seed.repo_root.clone(); } roots.sort_by(|a, b| { root_preference_score(a) .cmp(&root_preference_score(b)) .then_with(|| a.to_string_lossy().len().cmp(&b.to_string_lossy().len())) }); roots[0].clone() } fn root_preference_score(path: &Path) -> u8 { let text = path.to_string_lossy(); if text.contains("/.jj/workspaces/") { 2 } else if text.contains("/private/tmp/") { 1 } else { 0 } } fn resolve_jj_repo_store(workspace_root: &Path) -> Result { let repo_marker = workspace_root.join(".jj").join("repo"); if repo_marker.is_dir() { return Ok(canonical_or_same(repo_marker)); } if repo_marker.is_file() { let target = fs::read_to_string(&repo_marker) .with_context(|| format!("failed to read {}", repo_marker.display()))?; let resolved = resolve_path( repo_marker .parent() .ok_or_else(|| anyhow!("missing .jj parent for {}", repo_marker.display()))?, target.trim(), ); return Ok(canonical_or_same(resolved)); } bail!("expected {} to exist", repo_marker.display()) } fn jj_workspace_states(repo_root: &Path) -> Result> { let output = capture_trimmed_in( repo_root, "jj", &[ "workspace", "list", "-T", "name ++ \"\\t\" ++ target.commit_id().short() ++ \"\\t\" ++ target.parents().map(|p| p.local_bookmarks().map(|b| b.name()).join(\",\")).join(\",\") ++ \"\\t\" ++ target.empty() ++ \"\\t\" ++ target.conflict() ++ \"\\t\" ++ target.description().first_line() ++ \"\\n\"", ], )?; let mut workspaces = Vec::new(); for line in output.lines() { if line.trim().is_empty() { continue; } let mut parts = line.splitn(6, '\t'); let name = parts.next().unwrap_or("").trim(); if name.is_empty() { continue; } let target_commit = parts.next().unwrap_or("").trim().to_string(); let current_branches = split_csv(parts.next().unwrap_or("")); let dirty = !parse_bool(parts.next().unwrap_or("true")); let conflict = parse_bool(parts.next().unwrap_or("false")); let description = parts.next().unwrap_or("").trim().to_string(); workspaces.push(WorkspaceState { name: name.to_string(), target_commit, current_branches, dirty, conflict, description, }); } Ok(workspaces) } fn jj_bookmark_rows(repo_root: &Path) -> Result> { let output = capture_trimmed_in( repo_root, "jj", &[ "bookmark", "list", "--all-remotes", "-T", "name ++ \"\\t\" ++ remote ++ \"\\t\" ++ tracked ++ \"\\t\" ++ conflict ++ \"\\t\" ++ present ++ \"\\t\" ++ synced ++ \"\\t\" ++ if(tracked, tracking_ahead_count.exact(), \"\") ++ \"\\t\" ++ if(tracked, tracking_behind_count.exact(), \"\") ++ \"\\t\" ++ if(normal_target, normal_target.commit_id().short(), \"\") ++ \"\\n\"", ], )?; let mut rows = Vec::new(); for line in output.lines() { if line.trim().is_empty() { continue; } let mut parts = line.splitn(9, '\t'); let name = parts.next().unwrap_or("").trim(); if name.is_empty() { continue; } rows.push(JjBookmarkRow { name: name.to_string(), remote: non_empty(parts.next().unwrap_or("")), tracked: parse_bool(parts.next().unwrap_or("false")), conflict: parse_bool(parts.next().unwrap_or("false")), present: parse_bool(parts.next().unwrap_or("false")), synced: parse_bool(parts.next().unwrap_or("false")), ahead_count: parse_u32(parts.next().unwrap_or("")), behind_count: parse_u32(parts.next().unwrap_or("")), target_sha: non_empty(parts.next().unwrap_or("")), }); } Ok(rows) } fn jj_commit_meta_by_bookmark(repo_root: &Path) -> Result> { let output = capture_trimmed_in( repo_root, "jj", &[ "log", "-r", "bookmarks()", "--no-graph", "-T", "local_bookmarks.map(|b| b.name()).join(\",\") ++ \"\\t\" ++ commit_id.short() ++ \"\\t\" ++ commit_id.short() ++ \"\\t\" ++ description.first_line() ++ \"\\t\" ++ author.timestamp().utc().format(\"%Y-%m-%dT%H:%M:%SZ\") ++ \"\\n\"", ], )?; let mut commits = HashMap::new(); for line in output.lines() { if line.trim().is_empty() { continue; } let mut parts = line.splitn(5, '\t'); let names = parts.next().unwrap_or(""); let head_sha = parts.next().unwrap_or("").trim().to_string(); let short_sha = parts.next().unwrap_or("").trim().to_string(); let subject = parts.next().unwrap_or("").trim().to_string(); let updated_at = non_empty(parts.next().unwrap_or("")); for name in split_csv(names) { commits.entry(name).or_insert_with(|| CommitMeta { head_sha: head_sha.clone(), short_sha: short_sha.clone(), subject: subject.clone(), updated_at: updated_at.clone(), }); } } Ok(commits) } fn jj_repo_slug(repo_root: &Path) -> Option { let output = capture_trimmed_in(repo_root, "jj", &["git", "remote", "list"]).ok()?; for line in output.lines() { let mut parts = line.split_whitespace(); let remote = parts.next().unwrap_or(""); let url = parts.next().unwrap_or(""); if remote == "origin" { if let Some(slug) = parse_github_repo_slug(url) { return Some(slug); } } } output .lines() .find_map(|line| line.split_whitespace().nth(1)) .and_then(parse_github_repo_slug) } fn infer_default_branch_jj<'a>( rows: &[JjBookmarkRow], prs: impl Iterator, ) -> Option { let local_names = rows .iter() .filter(|row| row.remote.is_none() && row.present) .map(|row| row.name.as_str()) .collect::>(); if local_names.contains("main") { return Some("main".to_string()); } if local_names.contains("master") { return Some("master".to_string()); } let mut counts = HashMap::::new(); for pr in prs { *counts.entry(pr.base_ref_name.clone()).or_default() += 1; } counts .into_iter() .max_by_key(|(_, count)| *count) .map(|(branch, _)| branch) } fn git_worktree_states(repo_root: &Path) -> Result> { let output = capture_trimmed_in(repo_root, "git", &["worktree", "list", "--porcelain"])?; let mut worktrees = Vec::new(); let mut current_path: Option = None; let mut current_branch: Option = None; for line in output.lines().chain(std::iter::once("")) { let trimmed = line.trim(); if trimmed.is_empty() { if let Some(path) = current_path.take() { let dirty = path.exists() && capture_trimmed_in(&path, "git", &["status", "--porcelain"]) .map(|status| !status.trim().is_empty()) .unwrap_or(false); worktrees.push(GitWorktreeState { name: path .file_name() .and_then(|value| value.to_str()) .unwrap_or("worktree") .to_string(), path, branch: current_branch.take(), dirty, }); } current_branch = None; continue; } if let Some(path) = trimmed.strip_prefix("worktree ") { current_path = Some(PathBuf::from(path.trim())); continue; } if let Some(branch) = trimmed.strip_prefix("branch refs/heads/") { current_branch = Some(branch.trim().to_string()); } } Ok(worktrees) } fn git_branch_rows(repo_root: &Path) -> Result> { let output = capture_trimmed_in( repo_root, "git", &[ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso-strict)%09%(subject)%09%(upstream:short)%09%(upstream:track)", "refs/heads", ], )?; let mut branches = Vec::new(); for line in output.lines() { if line.trim().is_empty() { continue; } let mut parts = line.splitn(6, '\t'); let name = parts.next().unwrap_or("").trim(); if name.is_empty() { continue; } let head_sha = parts.next().unwrap_or("").trim().to_string(); let updated_at = non_empty(parts.next().unwrap_or("")); let subject = parts.next().unwrap_or("").trim().to_string(); let upstream = non_empty(parts.next().unwrap_or("")); let (ahead_count, behind_count) = parse_git_track(parts.next().unwrap_or("")); branches.push(GitBranchRow { name: name.to_string(), short_sha: truncate_sha(&head_sha), head_sha, updated_at, subject, upstream, ahead_count, behind_count, }); } Ok(branches) } fn git_repo_slug(repo_root: &Path) -> Option { capture_trimmed_in(repo_root, "git", &["remote", "get-url", "origin"]) .ok() .as_deref() .and_then(parse_github_repo_slug) } fn git_default_branch(repo_root: &Path) -> Option { capture_trimmed_in(repo_root, "git", &["symbolic-ref", "refs/remotes/origin/HEAD"]) .ok() .and_then(|value| value.trim().rsplit('/').next().map(|branch| branch.to_string())) .or_else(|| { let branches = capture_trimmed_in( repo_root, "git", &["for-each-ref", "--format=%(refname:short)", "refs/heads"], ) .ok()?; let names = branches.lines().map(str::trim).collect::>(); if names.contains("main") { Some("main".to_string()) } else if names.contains("master") { Some("master".to_string()) } else { None } }) } fn fetch_open_prs( repo_slug: Option<&str>, ) -> (HashMap, Option) { let Some(repo_slug) = repo_slug else { return (HashMap::new(), None); }; let output = capture_trimmed( "gh", &[ "pr", "list", "--repo", repo_slug, "--state", "open", "--limit", "200", "--json", "number,title,url,state,isDraft,baseRefName,headRefName,updatedAt,reviewDecision", ], ); let output = match output { Ok(output) => output, Err(err) => return (HashMap::new(), Some(err.to_string())), }; let prs: Vec = match serde_json::from_str(&output) { Ok(prs) => prs, Err(err) => { return ( HashMap::new(), Some(format!("failed to parse gh pr list for {repo_slug}: {err}")), ); } }; let by_head = prs .into_iter() .map(|pr| (pr.head_ref_name.clone(), pr)) .collect::>(); (by_head, None) } fn sort_branches(branches: &mut [WorkflowBranchSnapshot]) { branches.sort_by(|a, b| { branch_rank(b) .cmp(&branch_rank(a)) .then_with(|| b.updated_at.cmp(&a.updated_at)) .then_with(|| a.name.cmp(&b.name)) }); } fn branch_rank(branch: &WorkflowBranchSnapshot) -> u8 { if branch.conflict { 5 } else if branch.pull_request.is_some() { 4 } else if branch.is_current { 3 } else if branch.dirty { 2 } else if branch.is_active { 1 } else { 0 } } fn is_hidden_branch(name: &str) -> bool { name.starts_with("backup/") || name.starts_with("jj/keep/") } fn display_repo_name(repo_slug: Option<&str>, repo_root: &Path) -> String { repo_slug .and_then(|slug| slug.rsplit('/').next()) .map(str::to_string) .or_else(|| { repo_root .file_name() .and_then(|value| value.to_str()) .map(str::to_string) }) .unwrap_or_else(|| "repo".to_string()) } fn relative_display_path(root: &Path, path: &Path) -> String { match path.strip_prefix(root) { Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(), Ok(relative) => relative.display().to_string(), Err(_) => path.display().to_string(), } } fn parse_git_track(value: &str) -> (Option, Option) { let trimmed = value.trim(); if trimmed.is_empty() { return (None, None); } let ahead = extract_number_after(trimmed, "ahead "); let behind = extract_number_after(trimmed, "behind "); (ahead, behind) } fn extract_number_after(text: &str, needle: &str) -> Option { let start = text.find(needle)? + needle.len(); let digits = text[start..] .chars() .take_while(|ch| ch.is_ascii_digit()) .collect::(); digits.parse().ok() } fn parse_dirty_conflict_state(value: Option<&str>) -> (bool, bool) { let Some(value) = value else { return (false, false); }; let mut parts = value.splitn(2, '\t'); let empty = parse_bool(parts.next().unwrap_or("true")); let conflict = parse_bool(parts.next().unwrap_or("false")); (!empty, conflict) } fn git_status_line_has_conflict(line: &str) -> bool { let bytes = line.as_bytes(); if bytes.len() < 2 { return false; } matches!( (bytes[0] as char, bytes[1] as char), ('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D') ) } fn parse_github_repo_slug(url: &str) -> Option { let trimmed = url.trim().trim_end_matches(".git").trim_end_matches('/'); if let Some(rest) = trimmed.strip_prefix("git@github.com:") { return normalize_repo_slug(rest); } let marker = "github.com/"; let start = trimmed.find(marker)?; normalize_repo_slug(&trimmed[start + marker.len()..]) } fn normalize_repo_slug(rest: &str) -> Option { let mut parts = rest.split('/').filter(|part| !part.is_empty()); let owner = parts.next()?; let repo = parts.next()?; Some(format!("{owner}/{repo}")) } fn capture_trimmed(command: &str, args: &[&str]) -> Result { let mut cmd = Command::new(command); cmd.args(args); capture_trimmed_inner(&mut cmd) } fn capture_trimmed_in(cwd: &Path, command: &str, args: &[&str]) -> Result { let mut cmd = Command::new(command); cmd.args(args).current_dir(cwd); capture_trimmed_inner(&mut cmd) } fn capture_trimmed_inner(cmd: &mut Command) -> Result { let rendered = format!("{cmd:?}"); let output = cmd .output() .with_context(|| format!("failed to run {rendered}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("command failed: {rendered}: {}", stderr.trim()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn resolve_path(base: &Path, value: &str) -> PathBuf { let path = PathBuf::from(value); if path.is_absolute() { path } else { base.join(path) } } fn canonical_or_same(path: PathBuf) -> PathBuf { path.canonicalize().unwrap_or(path) } fn parse_bool(value: &str) -> bool { value.trim() == "true" } fn parse_u32(value: &str) -> Option { value.trim().parse().ok() } fn split_csv(value: &str) -> Vec { value .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(str::to_string) .collect() } fn first_non_empty_csv(value: &str) -> Option { split_csv(value).into_iter().next() } fn non_empty(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn truncate_sha(value: &str) -> String { value.chars().take(12).collect() } fn now_iso() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } #[allow(dead_code)] fn ms_to_iso(ms: u128) -> Option { let ms = i64::try_from(ms).ok()?; Utc.timestamp_millis_opt(ms) .single() .map(|ts| ts.to_rfc3339_opts(SecondsFormat::Secs, true)) } ================================================ FILE: test-extension.md ================================================ # Testing Pi-Mono Extensibility This directory contains a test extension demonstrating pi-mono's extensibility. ## Setup The extension is at `.pi/extensions/test-extensibility.ts` and loads automatically. ## Run ```bash cd ~/code/flow pi ``` ## Test Commands Once pi is running, try these: ### 1. Test Custom Tool ``` use the counter tool to increment by 5 ``` ``` increment the counter 3 times, then show me the value ``` ### 2. Test Event Hooks ``` run: echo "hello world" ``` (Watch the console for `[test-ext] Tool called: bash`) ``` run: rm -i test.txt ``` (Should show a warning notification) ### 3. Test Custom Command Type directly: ``` /count ``` ### 4. View Extension Logs The extension logs to console. Look for `[test-ext]` prefixed messages. ## What This Demonstrates 1. **Custom Tools** - `counter` tool with multiple actions 2. **Event Hooks** - `tool_call` and `turn_end` listeners 3. **Custom Commands** - `/count` slash command 4. **Session Events** - Reset state on `session_start` 5. **UI Interactions** - `ctx.ui.notify()` for warnings ## Extending Further Edit `.pi/extensions/test-extensibility.ts` to: - Add more tools - Block dangerous operations (return `{ block: true }`) - Add keyboard shortcuts with `pi.registerShortcut()` - Register custom LLM providers with `pi.registerProvider()` ================================================ FILE: tests/deps.ts ================================================ #!/usr/bin/env tsx /** * Quick e2e check that tasks with managed deps run inside the generated env. * Uses a fake `flox` shim so no real installs or network calls are needed. */ import { mkdtempSync, writeFileSync, chmodSync, mkdirSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { spawnSync } from "child_process"; function assertOk(result: ReturnType, context: string) { if (result.status !== 0) { const stdout = result.stdout?.toString() ?? ""; const stderr = result.stderr?.toString() ?? ""; throw new Error( `${context} failed (code ${result.status})\nstdout:\n${stdout}\nstderr:\n${stderr}` ); } } function main() { const base = mkdtempSync(join(tmpdir(), "flow-flox-test-")); const binDir = join(base, "bin"); mkdirSync(binDir, { recursive: true }); const fakeFlox = join(base, "flox"); const helloDep = join(binDir, "hello-dep"); // Fake flox binary: lock-manifest echoes the manifest; activate adds our bin to PATH then execs the command. const floxScript = `#!/usr/bin/env bash set -e if [[ "$1" == "lock-manifest" ]]; then cat "$2" exit 0 fi if [[ "$1" == "activate" ]]; then shift while [[ "$1" != "--" && "$#" -gt 0 ]]; do shift; done shift || true export PATH="${binDir}:$PATH" exec "$@" fi printf "unknown flox args: %s\n" "$@" 1>&2 exit 1 `; writeFileSync(fakeFlox, floxScript, { encoding: "utf8" }); chmodSync(fakeFlox, 0o755); // Fake dependency command writeFileSync(helloDep, "#!/usr/bin/env bash\necho from-managed-env\n", { encoding: "utf8", }); chmodSync(helloDep, 0o755); // flow.toml using a managed dependency const flowToml = `version = 1 [deps.hello] pkg-path = "hello-dep" [[tasks]] name = "use-managed-dep" command = "hello-dep" description = "Confirm managed dep is used" dependencies = ["hello"] `; writeFileSync(join(base, "flow.toml"), flowToml, { encoding: "utf8" }); const env = { ...process.env, PATH: `${fakeFlox}:${process.env.PATH}`, HOME: base, FLOX_NO_TELEMETRY: "1", }; const cargo = spawnSync( "cargo", ["run", "--bin", "f", "--", "run", "use-managed-dep"], { cwd: base, env, } ); assertOk(cargo, "cargo run f use-managed-dep"); const output = cargo.stdout?.toString() ?? ""; if (!output.includes("from-managed-env")) { throw new Error(`unexpected task output:\n${output}`); } console.log("deps e2e passed:\n" + output.trim()); } main(); ================================================ FILE: tests/test_log_server.ts ================================================ #!/usr/bin/env bun /** * Test script for flow log server ingestion and query. * Run: bun tests/test_log_server.ts */ const SERVER_URL = "http://127.0.0.1:9060"; interface LogEntry { project: string; content: string; timestamp: number; type: string; service: string; stack?: string; format: string; } interface StoredLogEntry { id: number; project: string; content: string; timestamp: number; type: string; service: string; stack?: string; format: string; } async function checkHealth(): Promise { try { const res = await fetch(`${SERVER_URL}/health`); const data = await res.json(); console.log("✓ Health check:", data); return data.status === "ok"; } catch (e) { console.error("✗ Health check failed:", e); return false; } } async function ingestLog(entry: LogEntry): Promise<{ inserted: number; ids: number[] } | null> { try { console.log("\n→ Ingesting log:", JSON.stringify(entry)); const res = await fetch(`${SERVER_URL}/logs/ingest`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(entry), }); console.log(" Response status:", res.status); const text = await res.text(); console.log(" Response body:", text); if (!res.ok) { console.error("✗ Ingest failed with status:", res.status); return null; } return JSON.parse(text); } catch (e) { console.error("✗ Ingest error:", e); return null; } } async function queryLogs(project?: string): Promise { try { const url = project ? `${SERVER_URL}/logs/query?project=${encodeURIComponent(project)}` : `${SERVER_URL}/logs/query`; console.log("\n→ Querying logs:", url); const res = await fetch(url); console.log(" Response status:", res.status); const text = await res.text(); console.log(" Response body:", text); if (!res.ok) { console.error("✗ Query failed with status:", res.status); return []; } return JSON.parse(text); } catch (e) { console.error("✗ Query error:", e); return []; } } async function main() { console.log("=== Flow Log Server Test ===\n"); console.log("Server URL:", SERVER_URL); // 1. Health check console.log("\n--- Step 1: Health Check ---"); const healthy = await checkHealth(); if (!healthy) { console.error("\n✗ Server is not healthy. Make sure 'f server' is running."); process.exit(1); } // 2. Query existing logs (baseline) console.log("\n--- Step 2: Query Existing Logs (baseline) ---"); const existingLogs = await queryLogs(); console.log(`Found ${existingLogs.length} existing logs`); // 3. Ingest a test log console.log("\n--- Step 3: Ingest Test Log ---"); const testEntry: LogEntry = { project: "test-project", content: `Test log at ${new Date().toISOString()}`, timestamp: Date.now(), type: "log", service: "test-runner", format: "text", }; const ingestResult = await ingestLog(testEntry); if (!ingestResult) { console.error("\n✗ Failed to ingest log"); process.exit(1); } console.log("✓ Ingested:", ingestResult); // 4. Ingest an error log with stack trace console.log("\n--- Step 4: Ingest Error Log ---"); const errorEntry: LogEntry = { project: "test-project", content: "TypeError: Cannot read property 'foo' of undefined", timestamp: Date.now(), type: "error", service: "api", stack: "at Object. (test.ts:10:5)\nat Module._compile (node:internal/modules/cjs/loader:1234:14)", format: "text", }; const errorResult = await ingestLog(errorEntry); if (!errorResult) { console.error("\n✗ Failed to ingest error log"); process.exit(1); } console.log("✓ Ingested error:", errorResult); // 5. Query logs for our test project console.log("\n--- Step 5: Query Test Project Logs ---"); const projectLogs = await queryLogs("test-project"); console.log(`Found ${projectLogs.length} logs for test-project`); // 6. Query all logs console.log("\n--- Step 6: Query All Logs ---"); const allLogs = await queryLogs(); console.log(`Found ${allLogs.length} total logs`); // 7. Verify results console.log("\n--- Step 7: Verification ---"); if (projectLogs.length >= 2) { console.log("✓ Successfully ingested and queried logs!"); console.log("\nSample log entry:"); console.log(JSON.stringify(projectLogs[0], null, 2)); } else { console.error("✗ Expected at least 2 logs, got:", projectLogs.length); console.error("This suggests logs are not being persisted correctly."); process.exit(1); } console.log("\n=== All tests passed! ==="); } main().catch(console.error); ================================================ FILE: tools/domainsd-cpp/domainsd.cpp ================================================ #include #include #ifdef __APPLE__ #include #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr const char* kHeaderName = "X-Flow-Domainsd"; constexpr const char* kHeaderValue = "1"; constexpr size_t kMaxHeaderBytes = 1024 * 1024; constexpr size_t kIoBufferSize = 16 * 1024; constexpr size_t kDefaultPoolMaxIdlePerKey = 8; constexpr size_t kDefaultPoolMaxIdleTotal = 256; constexpr int kDefaultPoolIdleTimeoutMs = 15'000; constexpr int kDefaultPoolMaxAgeMs = 120'000; constexpr int kDefaultUpstreamConnectTimeoutMs = 10'000; constexpr int kDefaultUpstreamIoTimeoutMs = 15'000; constexpr int kDefaultClientIoTimeoutMs = 30'000; constexpr int kDefaultMaxActiveClients = 128; constexpr int kDefaultRouteReloadCheckIntervalMs = 100; std::atomic g_running{true}; int g_listen_fd = -1; std::string g_pidfile; std::atomic g_active_clients{0}; std::atomic g_overload_rejections{0}; size_t g_pool_max_idle_per_key = kDefaultPoolMaxIdlePerKey; size_t g_pool_max_idle_total = kDefaultPoolMaxIdleTotal; std::chrono::milliseconds g_pool_idle_timeout{kDefaultPoolIdleTimeoutMs}; std::chrono::milliseconds g_pool_max_age{kDefaultPoolMaxAgeMs}; int g_upstream_connect_timeout_ms = kDefaultUpstreamConnectTimeoutMs; int g_upstream_io_timeout_ms = kDefaultUpstreamIoTimeoutMs; int g_client_io_timeout_ms = kDefaultClientIoTimeoutMs; int g_max_active_clients = kDefaultMaxActiveClients; bool try_acquire_client_slot() { int prev = g_active_clients.fetch_add(1, std::memory_order_acq_rel); if (prev >= g_max_active_clients) { g_active_clients.fetch_sub(1, std::memory_order_acq_rel); g_overload_rejections.fetch_add(1, std::memory_order_relaxed); return false; } return true; } void release_client_slot() { g_active_clients.fetch_sub(1, std::memory_order_acq_rel); } std::string trim(const std::string& s) { size_t begin = 0; while (begin < s.size() && std::isspace(static_cast(s[begin]))) { begin++; } size_t end = s.size(); while (end > begin && std::isspace(static_cast(s[end - 1]))) { end--; } return s.substr(begin, end - begin); } std::string to_lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); return s; } std::string strip_port_from_host(const std::string& host) { auto pos = host.find(':'); if (pos == std::string::npos) { return host; } return host.substr(0, pos); } bool parse_host_port(const std::string& target, std::string& host, int& port) { auto pos = target.rfind(':'); if (pos == std::string::npos || pos == 0 || pos + 1 >= target.size()) { return false; } host = target.substr(0, pos); try { port = std::stoi(target.substr(pos + 1)); } catch (...) { return false; } return port >= 1 && port <= 65535; } bool send_all(int fd, const char* data, size_t len) { size_t off = 0; while (off < len) { ssize_t n = ::send(fd, data + off, len - off, 0); if (n <= 0) { if (errno == EINTR) { continue; } return false; } off += static_cast(n); } return true; } bool send_all(int fd, const std::string& data) { return send_all(fd, data.data(), data.size()); } void send_simple_response(int fd, int status, const std::string& reason, const std::string& body) { std::ostringstream out; out << "HTTP/1.1 " << status << " " << reason << "\r\n" << kHeaderName << ": " << kHeaderValue << "\r\n" << "Content-Type: text/plain; charset=utf-8\r\n" << "Content-Length: " << body.size() << "\r\n" << "Connection: close\r\n\r\n" << body; (void)send_all(fd, out.str()); } struct Request { std::string method; std::string path; std::string version; std::vector> headers; std::unordered_map headers_lc; std::string body; std::string leftover; std::string normalized_host; bool chunked = false; bool client_wants_keepalive = false; }; bool iequals_ascii(std::string_view a, std::string_view b) { if (a.size() != b.size()) { return false; } for (size_t i = 0; i < a.size(); ++i) { const unsigned char ac = static_cast(a[i]); const unsigned char bc = static_cast(b[i]); if (std::tolower(ac) != std::tolower(bc)) { return false; } } return true; } bool should_skip_forward_header(std::string_view key) { return iequals_ascii(key, "host") || iequals_ascii(key, "connection") || iequals_ascii(key, "proxy-connection") || iequals_ascii(key, "x-forwarded-for") || iequals_ascii(key, "x-forwarded-host") || iequals_ascii(key, "x-forwarded-proto") || iequals_ascii(key, "content-length") || iequals_ascii(key, "transfer-encoding"); } bool request_wants_keepalive(const Request& req) { bool connection_close = false; bool connection_keepalive = false; if (auto it = req.headers_lc.find("connection"); it != req.headers_lc.end()) { const std::string connection = to_lower(it->second); connection_close = connection.find("close") != std::string::npos; connection_keepalive = connection.find("keep-alive") != std::string::npos; } const std::string version = to_lower(req.version); if (version == "http/1.1") { return !connection_close; } if (version == "http/1.0") { return connection_keepalive; } return false; } bool recv_append(int fd, std::string& buf, std::string& error) { char tmp[kIoBufferSize]; while (true) { ssize_t n = ::recv(fd, tmp, sizeof(tmp), 0); if (n == 0) { error = "client closed connection"; return false; } if (n < 0) { if (errno == EINTR) { continue; } error = std::string("recv failed: ") + std::strerror(errno); return false; } buf.append(tmp, static_cast(n)); return true; } } bool ensure_bytes_available(int fd, std::string& buf, size_t need, std::string& error) { while (buf.size() < need) { if (!recv_append(fd, buf, error)) { return false; } } return true; } bool decode_chunked_body(int fd, std::string initial, std::string& out_body, std::string& leftover, std::string& error) { out_body.clear(); size_t cursor = 0; std::string buf = std::move(initial); for (;;) { while (true) { auto line_end = buf.find("\r\n", cursor); if (line_end != std::string::npos) { const std::string line = trim(buf.substr(cursor, line_end - cursor)); cursor = line_end + 2; const auto semi = line.find(';'); const std::string size_str = semi == std::string::npos ? line : line.substr(0, semi); size_t chunk_size = 0; try { chunk_size = static_cast(std::stoull(size_str, nullptr, 16)); } catch (...) { error = "invalid chunk size"; return false; } if (!ensure_bytes_available(fd, buf, cursor + chunk_size + 2, error)) { return false; } if (chunk_size == 0) { // Consume trailer headers until empty line. for (;;) { auto trailer_end = buf.find("\r\n", cursor); while (trailer_end == std::string::npos) { if (!recv_append(fd, buf, error)) { return false; } trailer_end = buf.find("\r\n", cursor); } const std::string trailer_line = buf.substr(cursor, trailer_end - cursor); cursor = trailer_end + 2; if (trailer_line.empty()) { leftover = buf.substr(cursor); return true; } } } out_body.append(buf, cursor, chunk_size); cursor += chunk_size; if (buf.substr(cursor, 2) != "\r\n") { error = "invalid chunk terminator"; return false; } cursor += 2; break; } if (!recv_append(fd, buf, error)) { return false; } } } } bool read_request(int client_fd, std::string& pending, Request& req, std::string& error) { req = Request{}; std::string buf = std::move(pending); pending.clear(); if (buf.capacity() < 8192) { buf.reserve(8192); } char tmp[kIoBufferSize]; size_t header_end = std::string::npos; while (true) { header_end = buf.find("\r\n\r\n"); if (header_end != std::string::npos) { break; } if (buf.size() > kMaxHeaderBytes) { error = "request headers too large"; return false; } ssize_t n = ::recv(client_fd, tmp, sizeof(tmp), 0); if (n == 0) { error = "client closed before request"; return false; } if (n < 0) { if (errno == EINTR) { continue; } error = std::string("recv failed: ") + std::strerror(errno); return false; } buf.append(tmp, static_cast(n)); } const size_t headers_len = header_end + 4; std::string headers_blob = buf.substr(0, headers_len); std::istringstream header_stream(headers_blob); std::string line; if (!std::getline(header_stream, line)) { error = "missing request line"; return false; } if (!line.empty() && line.back() == '\r') { line.pop_back(); } { std::istringstream rl(line); if (!(rl >> req.method >> req.path >> req.version)) { error = "invalid request line"; return false; } } while (std::getline(header_stream, line)) { if (!line.empty() && line.back() == '\r') { line.pop_back(); } if (line.empty()) { break; } auto pos = line.find(':'); if (pos == std::string::npos) { continue; } std::string key = trim(line.substr(0, pos)); std::string val = trim(line.substr(pos + 1)); req.headers.emplace_back(key, val); req.headers_lc[to_lower(key)] = val; } if (auto host_it = req.headers_lc.find("host"); host_it != req.headers_lc.end()) { req.normalized_host = to_lower(strip_port_from_host(trim(host_it->second))); } bool chunked = false; size_t content_length = 0; if (auto it = req.headers_lc.find("content-length"); it != req.headers_lc.end()) { try { content_length = static_cast(std::stoul(it->second)); } catch (...) { error = "invalid content-length"; return false; } } if (auto it = req.headers_lc.find("transfer-encoding"); it != req.headers_lc.end()) { if (to_lower(it->second).find("chunked") != std::string::npos) { chunked = true; } } req.chunked = chunked; std::string initial = buf.substr(headers_len); if (chunked) { const bool ok = decode_chunked_body(client_fd, std::move(initial), req.body, req.leftover, error); if (ok) { req.client_wants_keepalive = request_wants_keepalive(req); pending = req.leftover; } return ok; } if (initial.size() >= content_length) { req.body = initial.substr(0, content_length); req.leftover = initial.substr(content_length); req.client_wants_keepalive = request_wants_keepalive(req); pending = req.leftover; return true; } req.body = std::move(initial); req.body.reserve(content_length); while (req.body.size() < content_length) { ssize_t n = ::recv(client_fd, tmp, sizeof(tmp), 0); if (n <= 0) { if (n < 0 && errno == EINTR) { continue; } error = "client closed before full request body"; return false; } req.body.append(tmp, static_cast(n)); } if (req.body.size() > content_length) { req.leftover = req.body.substr(content_length); req.body.resize(content_length); } req.client_wants_keepalive = request_wants_keepalive(req); pending = req.leftover; return true; } class RouteTable { public: explicit RouteTable(std::string routes_path) : routes_path_(std::move(routes_path)) {} std::optional lookup(const std::string& host) { reload_if_needed(); std::lock_guard lock(mu_); auto it = routes_.find(to_lower(host)); if (it == routes_.end()) { return std::nullopt; } return it->second; } size_t size() { reload_if_needed(); std::lock_guard lock(mu_); return routes_.size(); } private: void reload_if_needed() { const auto now = std::chrono::steady_clock::now(); { std::lock_guard lock(mu_); if (loaded_ && now - last_reload_check_ < std::chrono::milliseconds(kDefaultRouteReloadCheckIntervalMs)) { return; } last_reload_check_ = now; } std::error_code ec; auto current = std::filesystem::last_write_time(routes_path_, ec); if (ec) { return; } { std::lock_guard lock(mu_); if (loaded_ && current == mtime_) { return; } } std::ifstream in(routes_path_); if (!in) { return; } std::ostringstream raw; raw << in.rdbuf(); std::unordered_map parsed; static const std::regex pair_re("\\\"([^\\\"]+)\\\"\\s*:\\s*\\\"([^\\\"]*)\\\""); const std::string content = raw.str(); auto begin = std::sregex_iterator(content.begin(), content.end(), pair_re); auto end = std::sregex_iterator(); for (auto it = begin; it != end; ++it) { const std::string host = to_lower((*it)[1].str()); const std::string target = trim((*it)[2].str()); if (!host.empty() && !target.empty()) { parsed[host] = target; } } std::lock_guard lock(mu_); routes_ = std::move(parsed); mtime_ = current; loaded_ = true; } std::string routes_path_; std::unordered_map routes_; std::filesystem::file_time_type mtime_{}; std::chrono::steady_clock::time_point last_reload_check_{}; bool loaded_ = false; std::mutex mu_; }; void set_common_socket_opts(int fd) { int one = 1; (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); (void)setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one)); } void set_socket_timeouts_ms(int fd, int timeout_ms) { timeval tv{}; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; (void)setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); } bool set_nonblocking(int fd, bool nonblocking) { int flags = fcntl(fd, F_GETFL, 0); if (flags < 0) { return false; } if (nonblocking) { flags |= O_NONBLOCK; } else { flags &= ~O_NONBLOCK; } return fcntl(fd, F_SETFL, flags) == 0; } bool connect_with_timeout(int fd, const sockaddr* addr, socklen_t addrlen, int timeout_ms) { if (!set_nonblocking(fd, true)) { return false; } int rc = connect(fd, addr, addrlen); if (rc == 0) { (void)set_nonblocking(fd, false); return true; } if (errno != EINPROGRESS) { return false; } pollfd pfd{}; pfd.fd = fd; pfd.events = POLLOUT; while (true) { int prc = poll(&pfd, 1, timeout_ms); if (prc == 0) { errno = ETIMEDOUT; return false; } if (prc < 0) { if (errno == EINTR) { continue; } return false; } int so_error = 0; socklen_t slen = sizeof(so_error); if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &so_error, &slen) < 0) { return false; } if (so_error != 0) { errno = so_error; return false; } (void)set_nonblocking(fd, false); return true; } } int connect_upstream(const std::string& host, int port) { struct addrinfo hints; std::memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; struct addrinfo* res = nullptr; const std::string port_str = std::to_string(port); int rc = getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res); if (rc != 0) { return -1; } int fd = -1; for (auto* p = res; p != nullptr; p = p->ai_next) { fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); if (fd < 0) { continue; } set_common_socket_opts(fd); if (connect_with_timeout(fd, p->ai_addr, p->ai_addrlen, g_upstream_connect_timeout_ms)) { set_socket_timeouts_ms(fd, g_upstream_io_timeout_ms); break; } close(fd); fd = -1; } freeaddrinfo(res); return fd; } bool socket_is_idle_usable(int fd) { char c; ssize_t n = recv(fd, &c, 1, MSG_PEEK | MSG_DONTWAIT); if (n == 0) { return false; } if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { return true; } if (errno == EINTR) { return socket_is_idle_usable(fd); } return false; } // Data pending means stream is not in a clean idle state for reuse. return false; } struct PooledConn { int fd = -1; std::chrono::steady_clock::time_point created_at{}; std::chrono::steady_clock::time_point last_used_at{}; }; class UpstreamPool { public: ~UpstreamPool() { std::lock_guard lock(mu_); for (auto& [_, conns] : by_key_) { for (auto& conn : conns) { if (conn.fd >= 0) { close(conn.fd); } } } } int acquire(const std::string& key, const std::string& host, int port) { const auto now = std::chrono::steady_clock::now(); { std::lock_guard lock(mu_); reap_locked(now); auto it = by_key_.find(key); if (it != by_key_.end()) { auto& conns = it->second; while (!conns.empty()) { auto conn = conns.back(); conns.pop_back(); idle_total_ = idle_total_ > 0 ? idle_total_ - 1 : 0; if (!is_conn_fresh(now, conn) || !socket_is_idle_usable(conn.fd)) { close(conn.fd); continue; } return conn.fd; } } } return connect_upstream(host, port); } void release(const std::string& key, int fd) { if (fd < 0) { return; } if (!socket_is_idle_usable(fd)) { close(fd); return; } const auto now = std::chrono::steady_clock::now(); std::lock_guard lock(mu_); reap_locked(now); if (idle_total_ >= g_pool_max_idle_total) { close(fd); return; } auto& conns = by_key_[key]; if (conns.size() >= g_pool_max_idle_per_key) { close(fd); return; } conns.push_back(PooledConn{ .fd = fd, .created_at = now, .last_used_at = now, }); idle_total_++; } void discard(int fd) { if (fd >= 0) { close(fd); } } private: bool is_conn_fresh(const std::chrono::steady_clock::time_point& now, const PooledConn& conn) { if (now - conn.last_used_at > g_pool_idle_timeout) { return false; } if (now - conn.created_at > g_pool_max_age) { return false; } return true; } void reap_locked(const std::chrono::steady_clock::time_point& now) { for (auto it = by_key_.begin(); it != by_key_.end();) { auto& conns = it->second; size_t write = 0; for (size_t read = 0; read < conns.size(); ++read) { if (!is_conn_fresh(now, conns[read]) || !socket_is_idle_usable(conns[read].fd)) { close(conns[read].fd); idle_total_ = idle_total_ > 0 ? idle_total_ - 1 : 0; continue; } if (write != read) { conns[write] = conns[read]; } write++; } conns.resize(write); if (conns.empty()) { it = by_key_.erase(it); } else { ++it; } } } std::mutex mu_; std::unordered_map> by_key_; size_t idle_total_ = 0; }; UpstreamPool g_upstream_pool; bool is_upgrade_request(const Request& req) { auto upgrade_it = req.headers_lc.find("upgrade"); if (upgrade_it == req.headers_lc.end()) { return false; } auto conn_it = req.headers_lc.find("connection"); if (conn_it == req.headers_lc.end()) { return false; } return to_lower(conn_it->second).find("upgrade") != std::string::npos; } std::string build_upstream_request(const Request& req, const std::string& host_header, bool tunnel_upgrade, bool keepalive_upstream) { std::string out; out.reserve(512 + req.method.size() + req.path.size() + req.version.size() + req.body.size()); out.append(req.method).append(" ").append(req.path).append(" ").append(req.version).append("\r\n"); for (const auto& [key, value] : req.headers) { if (should_skip_forward_header(key)) { continue; } out.append(key).append(": ").append(value).append("\r\n"); } out.append("Host: ").append(host_header).append("\r\n"); auto host_it = req.headers_lc.find("host"); std::string original_host = host_it == req.headers_lc.end() ? host_header : host_it->second; out.append("X-Forwarded-Host: ").append(original_host).append("\r\n"); out.append("X-Forwarded-Proto: http\r\n"); if (tunnel_upgrade) { auto up_it = req.headers_lc.find("upgrade"); std::string up = up_it == req.headers_lc.end() ? "websocket" : up_it->second; out.append("Connection: Upgrade\r\n"); out.append("Upgrade: ").append(up).append("\r\n"); out.append("\r\n"); } else { out.append("Connection: ").append(keepalive_upstream ? "keep-alive" : "close").append("\r\n"); out.append("Content-Length: ").append(std::to_string(req.body.size())).append("\r\n\r\n"); out.append(req.body); } return out; } void shutdown_quiet(int fd, int how) { if (fd >= 0) { (void)shutdown(fd, how); } } void pump_fd(int src, int dst, std::atomic& done) { char buf[kIoBufferSize]; while (!done.load()) { ssize_t n = recv(src, buf, sizeof(buf), 0); if (n == 0) { break; } if (n < 0) { if (errno == EINTR) { continue; } break; } if (!send_all(dst, buf, static_cast(n))) { break; } } done.store(true); shutdown_quiet(dst, SHUT_WR); shutdown_quiet(src, SHUT_RD); } void tunnel_bidirectional(int a_fd, int b_fd) { std::atomic done{false}; std::thread upstream_to_client([&]() { pump_fd(b_fd, a_fd, done); }); pump_fd(a_fd, b_fd, done); upstream_to_client.join(); } struct ResponseMeta { int status_code = 0; bool chunked = false; bool connection_close = false; bool no_body = false; std::optional content_length; }; bool parse_response_headers(const std::string& raw_headers, const std::string& req_method, ResponseMeta& out) { std::istringstream s(raw_headers); std::string line; if (!std::getline(s, line)) { return false; } if (!line.empty() && line.back() == '\r') { line.pop_back(); } { std::istringstream first(line); std::string http_version; if (!(first >> http_version >> out.status_code)) { return false; } } while (std::getline(s, line)) { if (!line.empty() && line.back() == '\r') { line.pop_back(); } if (line.empty()) { break; } auto pos = line.find(':'); if (pos == std::string::npos) { continue; } auto key = to_lower(trim(line.substr(0, pos))); auto val = to_lower(trim(line.substr(pos + 1))); if (key == "transfer-encoding" && val.find("chunked") != std::string::npos) { out.chunked = true; } else if (key == "content-length") { try { out.content_length = static_cast(std::stoull(val)); } catch (...) { return false; } } else if (key == "connection" && val.find("close") != std::string::npos) { out.connection_close = true; } } const std::string method = to_lower(req_method); const bool informational = out.status_code >= 100 && out.status_code < 200 && out.status_code != 101; out.no_body = (method == "head") || informational || out.status_code == 204 || out.status_code == 304; if (out.no_body) { out.chunked = false; out.content_length = 0; } return true; } bool recv_append_upstream(int fd, std::string& buf) { char tmp[kIoBufferSize]; while (true) { ssize_t n = recv(fd, tmp, sizeof(tmp), 0); if (n == 0) { return false; } if (n < 0) { if (errno == EINTR) { continue; } return false; } buf.append(tmp, static_cast(n)); return true; } } bool relay_body_with_length(int upstream_fd, int client_fd, std::string body_buf, size_t body_len) { size_t sent = 0; if (!body_buf.empty()) { size_t first = std::min(body_buf.size(), body_len); if (first > 0 && !send_all(client_fd, body_buf.data(), first)) { return false; } sent += first; if (body_buf.size() > body_len) { return false; } } char tmp[kIoBufferSize]; while (sent < body_len) { ssize_t n = recv(upstream_fd, tmp, sizeof(tmp), 0); if (n == 0) { return false; } if (n < 0) { if (errno == EINTR) { continue; } return false; } size_t to_send = std::min(static_cast(n), body_len - sent); if (!send_all(client_fd, tmp, to_send)) { return false; } sent += to_send; if (static_cast(n) > to_send) { // Unexpected bytes beyond declared content-length. Treat as non-reusable. return false; } } return true; } bool relay_chunked_body(int upstream_fd, int client_fd, std::string buf) { size_t cursor = 0; for (;;) { while (true) { auto line_end = buf.find("\r\n", cursor); if (line_end == std::string::npos) { if (!recv_append_upstream(upstream_fd, buf)) { return false; } continue; } const std::string line = trim(buf.substr(cursor, line_end - cursor)); const auto semi = line.find(';'); const std::string size_str = semi == std::string::npos ? line : line.substr(0, semi); size_t chunk_size = 0; try { chunk_size = static_cast(std::stoull(size_str, nullptr, 16)); } catch (...) { return false; } const size_t chunk_prefix = line_end + 2; while (buf.size() < chunk_prefix + chunk_size + 2) { if (!recv_append_upstream(upstream_fd, buf)) { return false; } } if (buf.substr(chunk_prefix + chunk_size, 2) != "\r\n") { return false; } if (!send_all(client_fd, buf.data() + cursor, chunk_prefix + chunk_size + 2 - cursor)) { return false; } cursor = chunk_prefix + chunk_size + 2; if (chunk_size == 0) { // Forward trailers and ending CRLF. auto trailer_end = buf.find("\r\n\r\n", cursor); while (trailer_end == std::string::npos) { if (!recv_append_upstream(upstream_fd, buf)) { return false; } trailer_end = buf.find("\r\n\r\n", cursor); } const size_t end = trailer_end + 4; if (!send_all(client_fd, buf.data() + cursor, end - cursor)) { return false; } return end == buf.size(); } break; } } } struct RelayOutcome { bool upstream_reusable = false; bool client_can_keepalive = false; }; RelayOutcome relay_response_and_decide_reuse(int upstream_fd, int client_fd, const std::string& req_method) { std::string buf; buf.reserve(8192); size_t header_end = std::string::npos; while (header_end == std::string::npos) { header_end = buf.find("\r\n\r\n"); if (header_end != std::string::npos) { break; } if (!recv_append_upstream(upstream_fd, buf)) { return {}; } if (buf.size() > kMaxHeaderBytes) { return {}; } } const size_t hdr_len = header_end + 4; const std::string raw_headers = buf.substr(0, hdr_len); ResponseMeta meta; if (!parse_response_headers(raw_headers, req_method, meta)) { return {}; } if (!send_all(client_fd, raw_headers)) { return {}; } std::string body_buf = buf.substr(hdr_len); if (meta.no_body) { if (!body_buf.empty()) { if (!send_all(client_fd, body_buf)) { return {}; } return {}; } return { .upstream_reusable = !meta.connection_close, .client_can_keepalive = !meta.connection_close, }; } if (meta.chunked) { bool complete = relay_chunked_body(upstream_fd, client_fd, std::move(body_buf)); const bool keepalive = complete && !meta.connection_close; return { .upstream_reusable = keepalive, .client_can_keepalive = keepalive, }; } if (meta.content_length.has_value()) { bool ok = relay_body_with_length(upstream_fd, client_fd, std::move(body_buf), *meta.content_length); const bool keepalive = ok && !meta.connection_close; return { .upstream_reusable = keepalive, .client_can_keepalive = keepalive, }; } // Unknown body framing: read until close and do not reuse socket. if (!body_buf.empty() && !send_all(client_fd, body_buf)) { return {}; } char tmp[kIoBufferSize]; while (true) { ssize_t n = recv(upstream_fd, tmp, sizeof(tmp), 0); if (n == 0) { break; } if (n < 0) { if (errno == EINTR) { continue; } return {}; } if (!send_all(client_fd, tmp, static_cast(n))) { return {}; } } return {}; } void handle_client(int client_fd, RouteTable& routes) { std::string pending; int cached_upstream_fd = -1; std::string cached_upstream_key; auto discard_cached = [&]() { if (cached_upstream_fd >= 0) { g_upstream_pool.discard(cached_upstream_fd); cached_upstream_fd = -1; cached_upstream_key.clear(); } }; auto release_cached = [&]() { if (cached_upstream_fd >= 0 && !cached_upstream_key.empty()) { g_upstream_pool.release(cached_upstream_key, cached_upstream_fd); cached_upstream_fd = -1; cached_upstream_key.clear(); } }; while (g_running.load(std::memory_order_relaxed)) { Request req; std::string parse_error; if (!read_request(client_fd, pending, req, parse_error)) { if (!parse_error.empty() && parse_error != "client closed before request" && parse_error != "client closed connection") { send_simple_response(client_fd, 400, "Bad Request", parse_error + "\n"); } break; } if (req.path == "/_flow/domains/health") { std::ostringstream body; body << "ok active_clients=" << g_active_clients.load(std::memory_order_relaxed) << " overload_rejections=" << g_overload_rejections.load(std::memory_order_relaxed) << " max_active_clients=" << g_max_active_clients << " upstream_connect_timeout_ms=" << g_upstream_connect_timeout_ms << " upstream_io_timeout_ms=" << g_upstream_io_timeout_ms << " client_io_timeout_ms=" << g_client_io_timeout_ms << " pool_max_idle_per_key=" << g_pool_max_idle_per_key << " pool_max_idle_total=" << g_pool_max_idle_total << " pool_idle_timeout_ms=" << g_pool_idle_timeout.count() << " pool_max_age_ms=" << g_pool_max_age.count() << "\n"; const auto body_s = body.str(); std::ostringstream out; out << "HTTP/1.1 200 OK\r\n" << kHeaderName << ": " << kHeaderValue << "\r\n" << "Content-Type: text/plain; charset=utf-8\r\n" << "Content-Length: " << body_s.size() << "\r\n" << "Connection: " << (req.client_wants_keepalive ? "keep-alive" : "close") << "\r\n\r\n" << body_s; if (!send_all(client_fd, out.str()) || !req.client_wants_keepalive) { break; } continue; } if (req.normalized_host.empty()) { send_simple_response(client_fd, 400, "Bad Request", "Missing Host header\n"); break; } const std::string& req_host = req.normalized_host; auto target = routes.lookup(req_host); if (!target.has_value()) { std::ostringstream body; body << "No local route configured for " << req_host << "\n"; send_simple_response(client_fd, 404, "Not Found", body.str()); break; } std::string upstream_host; int upstream_port = 0; if (!parse_host_port(*target, upstream_host, upstream_port)) { send_simple_response(client_fd, 502, "Bad Gateway", "Invalid target route\n"); break; } const bool upgrade = is_upgrade_request(req); const std::string upstream_key = upstream_host + ":" + std::to_string(upstream_port); if (upgrade) { // Upgrade tunnels are one-shot; keepalive cache is irrelevant. release_cached(); } bool used_cached = false; int upstream_fd = -1; if (!upgrade && cached_upstream_fd >= 0 && cached_upstream_key == upstream_key) { upstream_fd = cached_upstream_fd; used_cached = true; } else { if (!upgrade) { release_cached(); } upstream_fd = upgrade ? connect_upstream(upstream_host, upstream_port) : g_upstream_pool.acquire(upstream_key, upstream_host, upstream_port); } if (upstream_fd < 0) { if (errno == ETIMEDOUT) { send_simple_response(client_fd, 504, "Gateway Timeout", "Upstream connect timed out\n"); } else { send_simple_response(client_fd, 502, "Bad Gateway", "Upstream connection failed\n"); } break; } std::string host_header = (upstream_host == "127.0.0.1" || upstream_host == "::1") ? "localhost" : upstream_host; std::string upstream_req = build_upstream_request(req, host_header, upgrade, true); if (!send_all(upstream_fd, upstream_req)) { // Stale keepalive sockets can fail first write; retry once with fresh socket. if (!upgrade && used_cached) { discard_cached(); upstream_fd = g_upstream_pool.acquire(upstream_key, upstream_host, upstream_port); if (upstream_fd >= 0 && send_all(upstream_fd, upstream_req)) { used_cached = false; } else if (upstream_fd >= 0) { g_upstream_pool.discard(upstream_fd); upstream_fd = -1; } } else if (upgrade) { close(upstream_fd); upstream_fd = -1; } else { g_upstream_pool.discard(upstream_fd); upstream_fd = -1; } if (upstream_fd < 0) { send_simple_response(client_fd, 502, "Bad Gateway", "Failed to forward request\n"); break; } } if (upgrade) { if (!req.leftover.empty() && !send_all(upstream_fd, req.leftover)) { close(upstream_fd); break; } tunnel_bidirectional(client_fd, upstream_fd); close(upstream_fd); break; } RelayOutcome relay = relay_response_and_decide_reuse(upstream_fd, client_fd, req.method); if (relay.upstream_reusable) { cached_upstream_fd = upstream_fd; cached_upstream_key = upstream_key; } else { g_upstream_pool.discard(upstream_fd); if (used_cached) { cached_upstream_fd = -1; cached_upstream_key.clear(); } } if (!(req.client_wants_keepalive && relay.client_can_keepalive)) { break; } } release_cached(); close(client_fd); } bool parse_listen(const std::string& listen, std::string& host, int& port) { return parse_host_port(listen, host, port); } bool parse_u64_arg(const std::string& raw, uint64_t& out) { if (raw.empty()) { return false; } size_t idx = 0; try { out = std::stoull(raw, &idx, 10); } catch (...) { return false; } return idx == raw.size(); } bool assign_positive_int(const std::string& value, int& target) { uint64_t parsed = 0; if (!parse_u64_arg(value, parsed) || parsed == 0 || parsed > static_cast(INT32_MAX)) { return false; } target = static_cast(parsed); return true; } bool assign_positive_size(const std::string& value, size_t& target) { uint64_t parsed = 0; if (!parse_u64_arg(value, parsed) || parsed == 0 || parsed > static_cast(std::numeric_limits::max())) { return false; } target = static_cast(parsed); return true; } void cleanup_pidfile() { if (!g_pidfile.empty()) { std::error_code ec; std::filesystem::remove(g_pidfile, ec); } } void on_signal(int) { g_running.store(false); if (g_listen_fd >= 0) { close(g_listen_fd); g_listen_fd = -1; } } int start_listener(const std::string& host, int port) { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { return -1; } int opt = 1; if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { close(fd); return -1; } sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast(port)); if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { close(fd); return -1; } if (bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { close(fd); return -1; } if (listen(fd, 256) < 0) { close(fd); return -1; } return fd; } int start_listener_from_launchd_socket(const std::string& socket_name) { #ifdef __APPLE__ int* fds = nullptr; size_t count = 0; const int rc = launch_activate_socket(socket_name.c_str(), &fds, &count); if (rc != 0) { errno = rc; return -1; } if (count == 0 || fds == nullptr) { errno = ENOENT; return -1; } int fd = fds[0]; for (size_t i = 1; i < count; ++i) { if (fds[i] >= 0) { close(fds[i]); } } std::free(fds); return fd; #else (void)socket_name; errno = ENOTSUP; return -1; #endif } void print_usage(const char* argv0) { std::cerr << "Usage: " << argv0 << " --listen 127.0.0.1:80 --routes --pidfile [options]\n" << "Options:\n" << " --launchd-socket (macOS only)\n" << " --max-active-clients \n" << " --upstream-connect-timeout-ms \n" << " --upstream-io-timeout-ms \n" << " --client-io-timeout-ms \n" << " --pool-max-idle-per-key \n" << " --pool-max-idle-total \n" << " --pool-idle-timeout-ms \n" << " --pool-max-age-ms \n"; } } // namespace int main(int argc, char** argv) { std::string listen = "127.0.0.1:80"; std::string routes_path; std::string pidfile; std::string launchd_socket_name; for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if ((arg == "-h") || (arg == "--help")) { print_usage(argv[0]); return 0; } if (arg == "--listen" && i + 1 < argc) { listen = argv[++i]; continue; } if (arg == "--routes" && i + 1 < argc) { routes_path = argv[++i]; continue; } if (arg == "--pidfile" && i + 1 < argc) { pidfile = argv[++i]; continue; } if (arg == "--launchd-socket" && i + 1 < argc) { launchd_socket_name = argv[++i]; continue; } if (arg == "--max-active-clients" && i + 1 < argc) { if (!assign_positive_int(argv[++i], g_max_active_clients)) { std::cerr << "Invalid value for --max-active-clients\n"; return 2; } continue; } if (arg == "--upstream-connect-timeout-ms" && i + 1 < argc) { if (!assign_positive_int(argv[++i], g_upstream_connect_timeout_ms)) { std::cerr << "Invalid value for --upstream-connect-timeout-ms\n"; return 2; } continue; } if (arg == "--upstream-io-timeout-ms" && i + 1 < argc) { if (!assign_positive_int(argv[++i], g_upstream_io_timeout_ms)) { std::cerr << "Invalid value for --upstream-io-timeout-ms\n"; return 2; } continue; } if (arg == "--client-io-timeout-ms" && i + 1 < argc) { if (!assign_positive_int(argv[++i], g_client_io_timeout_ms)) { std::cerr << "Invalid value for --client-io-timeout-ms\n"; return 2; } continue; } if (arg == "--pool-max-idle-per-key" && i + 1 < argc) { if (!assign_positive_size(argv[++i], g_pool_max_idle_per_key)) { std::cerr << "Invalid value for --pool-max-idle-per-key\n"; return 2; } continue; } if (arg == "--pool-max-idle-total" && i + 1 < argc) { if (!assign_positive_size(argv[++i], g_pool_max_idle_total)) { std::cerr << "Invalid value for --pool-max-idle-total\n"; return 2; } continue; } if (arg == "--pool-idle-timeout-ms" && i + 1 < argc) { int ms = 0; if (!assign_positive_int(argv[++i], ms)) { std::cerr << "Invalid value for --pool-idle-timeout-ms\n"; return 2; } g_pool_idle_timeout = std::chrono::milliseconds(ms); continue; } if (arg == "--pool-max-age-ms" && i + 1 < argc) { int ms = 0; if (!assign_positive_int(argv[++i], ms)) { std::cerr << "Invalid value for --pool-max-age-ms\n"; return 2; } g_pool_max_age = std::chrono::milliseconds(ms); continue; } std::cerr << "Unknown or incomplete argument: " << arg << "\n"; print_usage(argv[0]); return 2; } if (routes_path.empty() || pidfile.empty()) { print_usage(argv[0]); return 2; } std::string listen_host; int listen_port = 0; if (!parse_listen(listen, listen_host, listen_port)) { std::cerr << "Invalid --listen value: " << listen << "\n"; return 2; } if (g_pool_max_idle_total < g_pool_max_idle_per_key) { g_pool_max_idle_total = g_pool_max_idle_per_key; } g_pidfile = pidfile; { std::ofstream out(pidfile, std::ios::trunc); if (!out) { std::cerr << "Failed to write pid file: " << pidfile << "\n"; return 1; } out << getpid() << "\n"; } std::signal(SIGINT, on_signal); std::signal(SIGTERM, on_signal); if (!launchd_socket_name.empty()) { g_listen_fd = start_listener_from_launchd_socket(launchd_socket_name); } else { g_listen_fd = start_listener(listen_host, listen_port); } if (g_listen_fd < 0) { cleanup_pidfile(); if (!launchd_socket_name.empty()) { std::cerr << "Failed to activate launchd socket '" << launchd_socket_name << "' (" << std::strerror(errno) << ")\n"; } else { std::cerr << "Failed to bind " << listen_host << ":" << listen_port << " (" << std::strerror(errno) << ")\n"; } return 1; } if (!launchd_socket_name.empty()) { std::cerr << "domainsd-cpp listening via launchd socket '" << launchd_socket_name << "'\n"; } else { std::cerr << "domainsd-cpp listening on " << listen_host << ":" << listen_port << "\n"; } RouteTable routes(routes_path); while (g_running.load()) { sockaddr_in client_addr{}; socklen_t client_len = sizeof(client_addr); int client_fd = accept(g_listen_fd, reinterpret_cast(&client_addr), &client_len); if (client_fd < 0) { if (errno == EINTR) { continue; } if (!g_running.load()) { break; } continue; } set_socket_timeouts_ms(client_fd, g_client_io_timeout_ms); if (!try_acquire_client_slot()) { send_simple_response(client_fd, 503, "Service Unavailable", "Proxy overloaded, retry shortly\n"); close(client_fd); continue; } std::thread([client_fd, &routes]() { struct SlotGuard { ~SlotGuard() { release_client_slot(); } } guard; handle_client(client_fd, routes); }).detach(); } if (g_listen_fd >= 0) { close(g_listen_fd); g_listen_fd = -1; } cleanup_pidfile(); return 0; } ================================================ FILE: tools/domainsd-cpp/install-macos-launchd.sh ================================================ #!/usr/bin/env bash set -euo pipefail if [[ "$(uname -s)" != "Darwin" ]]; then echo "error: this installer is macOS-only" >&2 exit 1 fi if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then cat <<'EOF' Install native domainsd launchd socket-activation on macOS (port 80, no docker). Usage: sudo ./tools/domainsd-cpp/install-macos-launchd.sh EOF exit 0 fi if [[ "${EUID}" -ne 0 ]]; then exec sudo "$0" "$@" fi LABEL="dev.flow.domainsd" SOCKET_NAME="domainsd" PLIST_PATH="/Library/LaunchDaemons/${LABEL}.plist" TARGET_USER="${SUDO_USER:-}" if [[ -z "${TARGET_USER}" ]]; then TARGET_USER="$(stat -f '%Su' /dev/console)" fi if [[ -z "${TARGET_USER}" ]]; then echo "error: failed to determine target user" >&2 exit 1 fi TARGET_GROUP="$(id -gn "${TARGET_USER}")" TARGET_HOME="$(dscl . -read "/Users/${TARGET_USER}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" if [[ -z "${TARGET_HOME}" ]]; then TARGET_HOME="$(eval echo "~${TARGET_USER}")" fi if [[ ! -d "${TARGET_HOME}" ]]; then echo "error: target home does not exist: ${TARGET_HOME}" >&2 exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FLOW_REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)" SOURCE_PATH="${FLOW_REPO}/tools/domainsd-cpp/domainsd.cpp" if [[ ! -f "${SOURCE_PATH}" ]]; then echo "error: source missing: ${SOURCE_PATH}" >&2 exit 1 fi STATE_ROOT="${TARGET_HOME}/Library/Application Support/flow/local-domains" BIN_PATH="${STATE_ROOT}/domainsd-cpp" ROUTES_PATH="${STATE_ROOT}/routes.json" PID_PATH="${STATE_ROOT}/domainsd.pid" LOG_PATH="${STATE_ROOT}/domainsd.log" mkdir -p "${STATE_ROOT}" if [[ ! -f "${ROUTES_PATH}" ]]; then printf '{}\n' > "${ROUTES_PATH}" fi touch "${LOG_PATH}" rm -f "${PID_PATH}" echo "[domainsd-launchd] building native daemon..." /usr/bin/clang++ -std=c++20 -O3 -DNDEBUG -Wall -Wextra -pthread \ "${SOURCE_PATH}" \ -o "${BIN_PATH}" chown "${TARGET_USER}:${TARGET_GROUP}" "${BIN_PATH}" "${ROUTES_PATH}" "${LOG_PATH}" "${STATE_ROOT}" chmod 755 "${BIN_PATH}" chmod 644 "${ROUTES_PATH}" "${LOG_PATH}" cat > "${PLIST_PATH}" < Label ${LABEL} ProgramArguments ${BIN_PATH} --launchd-socket ${SOCKET_NAME} --routes ${ROUTES_PATH} --pidfile ${PID_PATH} UserName ${TARGET_USER} WorkingDirectory ${STATE_ROOT} RunAtLoad KeepAlive StandardOutPath ${LOG_PATH} StandardErrorPath ${LOG_PATH} Sockets ${SOCKET_NAME} SockNodeName 127.0.0.1 SockServiceName 80 SockType stream SockProtocol TCP EOF chown root:wheel "${PLIST_PATH}" chmod 644 "${PLIST_PATH}" echo "[domainsd-launchd] loading launchd service..." launchctl bootout "system/${LABEL}" >/dev/null 2>&1 || true launchctl bootstrap system "${PLIST_PATH}" launchctl enable "system/${LABEL}" >/dev/null 2>&1 || true launchctl kickstart -k "system/${LABEL}" sleep 0.3 if curl -fsS "http://127.0.0.1/_flow/domains/health" >/dev/null 2>&1; then echo "[domainsd-launchd] health check OK" else echo "[domainsd-launchd] warning: health check failed, inspect log: ${LOG_PATH}" >&2 fi cat < `host:port`) - WebSocket upgrade passthrough (full duplex tunnel) - request-side chunked transfer-encoding decode/forward - upstream keepalive connection pooling (safe framed-response reuse) - overload shedding with bounded active client handlers (`503` when saturated) - upstream connect/IO timeouts (`504` for connect timeout) - health endpoint: `GET /_flow/domains/health` - mtime-based route reload (no daemon restart required) - optional macOS launchd socket activation (`--launchd-socket `) for privileged `:80` bind without Docker Runtime tuning: - daemon supports CLI flags (`--max-active-clients`, `--upstream-*-timeout-ms`, `--pool-*`) - Flow passes tuning via env vars prefixed `FLOW_DOMAINS_NATIVE_*` Current limitations: - no HTTP/2/TLS yet The Flow CLI builds this binary automatically with `clang++` when needed. ## macOS native `:80` without Docker When direct bind to `127.0.0.1:80` is blocked by permissions, install launchd socket mode once: ```bash sudo ./tools/domainsd-cpp/install-macos-launchd.sh ``` This installs `dev.flow.domainsd` in `/Library/LaunchDaemons`, binds port `80` via launchd, and runs `domainsd-cpp` as your user with inherited socket fd. Uninstall: ```bash sudo ./tools/domainsd-cpp/uninstall-macos-launchd.sh ``` ================================================ FILE: tools/domainsd-cpp/uninstall-macos-launchd.sh ================================================ #!/usr/bin/env bash set -euo pipefail if [[ "$(uname -s)" != "Darwin" ]]; then echo "error: this uninstaller is macOS-only" >&2 exit 1 fi if [[ "${EUID}" -ne 0 ]]; then exec sudo "$0" "$@" fi LABEL="dev.flow.domainsd" PLIST_PATH="/Library/LaunchDaemons/${LABEL}.plist" echo "[domainsd-launchd] unloading service..." launchctl bootout "system/${LABEL}" >/dev/null 2>&1 || true launchctl disable "system/${LABEL}" >/dev/null 2>&1 || true if [[ -f "${PLIST_PATH}" ]]; then rm -f "${PLIST_PATH}" echo "[domainsd-launchd] removed ${PLIST_PATH}" fi echo "[domainsd-launchd] uninstalled." echo "Note: binary/routes/log files under ~/Library/Application Support/flow/local-domains were kept." ================================================ FILE: vendor.lock.toml ================================================ [flow_vendor] repo = "https://github.com/nikivdev/flow-vendor.git" branch = "main" checkout = ".vendor/flow-vendor" commit = "50060e8f7fbe3eabab911d4943a62698228c9f53" [[crate]] name = "axum" repo_path = "crates/axum" manifest_path = "manifests/axum.toml" materialized_path = "lib/vendor/axum" [[crate]] name = "reqwest" repo_path = "crates/reqwest" manifest_path = "manifests/reqwest.toml" materialized_path = "lib/vendor/reqwest" [[crate]] name = "tower-http" repo_path = "crates/tower-http" manifest_path = "manifests/tower-http.toml" materialized_path = "lib/vendor/tower-http" [[crate]] name = "ratatui" repo_path = "crates/ratatui" manifest_path = "manifests/ratatui.toml" materialized_path = "lib/vendor/ratatui" [[crate]] name = "url" repo_path = "crates/url" manifest_path = "manifests/url.toml" materialized_path = "lib/vendor/url" [[crate]] name = "crypto_secretbox" repo_path = "crates/crypto_secretbox" manifest_path = "manifests/crypto_secretbox.toml" materialized_path = "lib/vendor/crypto_secretbox" [[crate]] name = "portable-pty" repo_path = "crates/portable-pty" manifest_path = "manifests/portable-pty.toml" materialized_path = "lib/vendor/portable-pty" [[crate]] name = "tokio-stream" repo_path = "crates/tokio-stream" manifest_path = "manifests/tokio-stream.toml" materialized_path = "lib/vendor/tokio-stream" [[crate]] name = "tracing-subscriber" repo_path = "crates/tracing-subscriber" manifest_path = "manifests/tracing-subscriber.toml" materialized_path = "lib/vendor/tracing-subscriber" [[crate]] name = "futures" repo_path = "crates/futures" manifest_path = "manifests/futures.toml" materialized_path = "lib/vendor/futures" [[crate]] name = "sha1" repo_path = "crates/sha1" manifest_path = "manifests/sha1.toml" materialized_path = "lib/vendor/sha1" [[crate]] name = "sha2" repo_path = "crates/sha2" manifest_path = "manifests/sha2.toml" materialized_path = "lib/vendor/sha2" [[crate]] name = "tokio" repo_path = "crates/tokio" manifest_path = "manifests/tokio.toml" materialized_path = "lib/vendor/tokio" [[crate]] name = "crossterm" repo_path = "crates/crossterm" manifest_path = "manifests/crossterm.toml" materialized_path = "lib/vendor/crossterm" [[crate]] name = "hmac" repo_path = "crates/hmac" manifest_path = "manifests/hmac.toml" materialized_path = "lib/vendor/hmac" [[crate]] name = "toml" repo_path = "crates/toml" manifest_path = "manifests/toml.toml" materialized_path = "lib/vendor/toml" [[crate]] name = "clap" repo_path = "crates/clap" manifest_path = "manifests/clap.toml" materialized_path = "lib/vendor/clap" [[crate]] name = "notify-debouncer-mini" repo_path = "crates/notify-debouncer-mini" manifest_path = "manifests/notify-debouncer-mini.toml" materialized_path = "lib/vendor/notify-debouncer-mini" [[crate]] name = "ignore" repo_path = "crates/ignore" manifest_path = "manifests/ignore.toml" materialized_path = "lib/vendor/ignore" [[crate]] name = "x25519-dalek" repo_path = "crates/x25519-dalek" manifest_path = "manifests/x25519-dalek.toml" materialized_path = "lib/vendor/x25519-dalek" [[crate]] name = "rusqlite" repo_path = "crates/rusqlite" manifest_path = "manifests/rusqlite.toml" materialized_path = "lib/vendor/rusqlite" [[crate]] name = "rmp-serde" repo_path = "crates/rmp-serde" manifest_path = "manifests/rmp-serde.toml" materialized_path = "lib/vendor/rmp-serde" [[crate]] name = "ctrlc" repo_path = "crates/ctrlc" manifest_path = "manifests/ctrlc.toml" materialized_path = "lib/vendor/ctrlc" [[crate]] name = "notify" repo_path = "crates/notify" manifest_path = "manifests/notify.toml" materialized_path = "lib/vendor/notify" [[crate]] name = "regex" repo_path = "crates/regex" manifest_path = "manifests/regex.toml" materialized_path = "lib/vendor/regex" [[crate]] name = "serde" repo_path = "crates/serde" manifest_path = "manifests/serde.toml" materialized_path = "lib/vendor/serde"