From 558dceb9e5f37f789422978f05ce0cce48be568e Mon Sep 17 00:00:00 2001 From: Paul Breuler <4356863+paulbreuler@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:00:55 -0600 Subject: [PATCH 1/3] refactor(cli): reorganize commands into canonical groups --- README.md | 137 ++++---- packages/limps/package.json | 2 +- packages/limps/src/cli.tsx | 4 +- packages/limps/src/cli/config-cmd.ts | 2 +- packages/limps/src/cli/init-project.ts | 6 +- packages/limps/src/cli/next-task.ts | 2 +- packages/limps/src/commands/__complete.tsx | 22 ++ packages/limps/src/commands/completion.tsx | 34 ++ .../{create-doc.tsx => docs/create.tsx} | 21 +- .../{delete-doc.tsx => docs/delete.tsx} | 23 +- packages/limps/src/commands/docs/index.tsx | 40 +++ .../commands/{list-docs.tsx => docs/list.tsx} | 13 +- .../limps/src/commands/{ => docs}/process.tsx | 32 +- .../limps/src/commands/{ => docs}/reindex.tsx | 9 +- .../{search-docs.tsx => docs/search.tsx} | 23 +- .../src/commands/{ => docs}/tags/add.tsx | 27 +- .../src/commands/{ => docs}/tags/index.tsx | 12 +- .../src/commands/{ => docs}/tags/list.tsx | 25 +- .../src/commands/{ => docs}/tags/remove.tsx | 27 +- .../{update-doc.tsx => docs/update.tsx} | 23 +- packages/limps/src/commands/index.tsx | 40 +-- .../{list-agents.tsx => plan/agents.tsx} | 25 +- .../{create-plan.tsx => plan/create.tsx} | 12 +- packages/limps/src/commands/plan/index.tsx | 40 +++ .../{list-plans.tsx => plan/list.tsx} | 15 +- .../commands/{next-task.tsx => plan/next.tsx} | 21 +- .../{repair-plans.tsx => plan/repair.tsx} | 13 +- .../{score-task.tsx => plan/score.tsx} | 67 ++-- .../{score-all.tsx => plan/scores.tsx} | 48 ++- .../limps/src/commands/{ => plan}/status.tsx | 45 ++- .../commands/{serve.tsx => server/bridge.tsx} | 16 +- packages/limps/src/commands/server/index.tsx | 36 ++ .../limps/src/commands/{ => server}/start.tsx | 23 +- .../{server-status.tsx => server/status.tsx} | 17 +- .../limps/src/commands/{ => server}/stop.tsx | 9 +- packages/limps/src/core/command-context.ts | 13 + packages/limps/src/core/completion.ts | 325 ++++++++++++++++++ packages/limps/src/core/task-target.ts | 15 + packages/limps/src/server-http-entry.ts | 2 +- packages/limps/src/server-http.ts | 6 +- packages/limps/src/utils/daemon-manager.ts | 2 +- packages/limps/tests/cli/commands.test.tsx | 4 +- packages/limps/tests/cli/completion.test.ts | 103 ++++++ packages/limps/tests/cli/e2e.test.ts | 16 +- packages/limps/tests/cli/process.test.tsx | 32 +- .../tests/cli/score-task-command.test.tsx | 89 +++++ packages/limps/tests/cli/tags.test.ts | 20 +- 47 files changed, 1130 insertions(+), 408 deletions(-) create mode 100644 packages/limps/src/commands/__complete.tsx create mode 100644 packages/limps/src/commands/completion.tsx rename packages/limps/src/commands/{create-doc.tsx => docs/create.tsx} (81%) rename packages/limps/src/commands/{delete-doc.tsx => docs/delete.tsx} (81%) create mode 100644 packages/limps/src/commands/docs/index.tsx rename packages/limps/src/commands/{list-docs.tsx => docs/list.tsx} (86%) rename packages/limps/src/commands/{ => docs}/process.tsx (90%) rename packages/limps/src/commands/{ => docs}/reindex.tsx (89%) rename packages/limps/src/commands/{search-docs.tsx => docs/search.tsx} (82%) rename packages/limps/src/commands/{ => docs}/tags/add.tsx (82%) rename packages/limps/src/commands/{ => docs}/tags/index.tsx (59%) rename packages/limps/src/commands/{ => docs}/tags/list.tsx (82%) rename packages/limps/src/commands/{ => docs}/tags/remove.tsx (82%) rename packages/limps/src/commands/{update-doc.tsx => docs/update.tsx} (85%) rename packages/limps/src/commands/{list-agents.tsx => plan/agents.tsx} (77%) rename packages/limps/src/commands/{create-plan.tsx => plan/create.tsx} (84%) create mode 100644 packages/limps/src/commands/plan/index.tsx rename packages/limps/src/commands/{list-plans.tsx => plan/list.tsx} (77%) rename packages/limps/src/commands/{next-task.tsx => plan/next.tsx} (81%) rename packages/limps/src/commands/{repair-plans.tsx => plan/repair.tsx} (96%) rename packages/limps/src/commands/{score-task.tsx => plan/score.tsx} (54%) rename packages/limps/src/commands/{score-all.tsx => plan/scores.tsx} (67%) rename packages/limps/src/commands/{ => plan}/status.tsx (84%) rename packages/limps/src/commands/{serve.tsx => server/bridge.tsx} (90%) create mode 100644 packages/limps/src/commands/server/index.tsx rename packages/limps/src/commands/{ => server}/start.tsx (89%) rename packages/limps/src/commands/{server-status.tsx => server/status.tsx} (95%) rename packages/limps/src/commands/{ => server}/stop.tsx (91%) create mode 100644 packages/limps/src/core/command-context.ts create mode 100644 packages/limps/src/core/completion.ts create mode 100644 packages/limps/src/core/task-target.ts create mode 100644 packages/limps/tests/cli/completion.test.ts create mode 100644 packages/limps/tests/cli/score-task-command.test.tsx diff --git a/README.md b/README.md index 85d6dda..720b68e 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ cd ~/Documents/my-planning-docs limps init # Start the HTTP daemon -limps start +limps server start # → Daemon starts on http://127.0.0.1:4269/mcp # → PID file written to OS-standard location # → Ready for MCP client connections @@ -62,7 +62,7 @@ limps config print --client claude-code That's it. Your AI assistant now has access to your documents via HTTP transport. The folder can be anywhere—local, synced, or in a repo; limps does not require a git repository or a `plans/` directory. -**Tip:** `limps server-status` always includes system-wide daemon discovery. If a project config is found (or passed via `--config`), it also reconciles the configured project target against that global list. +**Tip:** `limps server status` always includes system-wide daemon discovery. If a project config is found (or passed via `--config`), it also reconciles the configured project target against that global list. ## Features @@ -81,7 +81,7 @@ That's it. Your AI assistant now has access to your documents via HTTP transport - **Local only** — Your data stays on disk (SQLite index + your files). No cloud, no subscription. - **Restart after changes** — If you change the indexed folder or config, restart the MCP server (or rely on the file watcher) so the index and tools reflect the current state. -- **Daemon management** — The HTTP server runs as a background process. Use `limps start`, `limps stop`, and `limps server-status` to manage the daemon lifecycle. PID files are stored in OS-standard directories for system-wide awareness. +- **Daemon management** — The HTTP server runs as a background process. Use `limps server start`, `limps server stop`, and `limps server status` to manage the daemon lifecycle. PID files are stored in OS-standard directories for system-wide awareness. - **Sandboxed user code** — `process_doc` and `process_docs` run your JavaScript in a QuickJS sandbox with time and memory limits; no network or Node APIs. - **One optional network call** — `limps version --check` fetches from the npm registry to compare versions. All other commands (serve, init, list, search, create/update/delete docs, process_doc, etc.) do **not** contact the internet. Omit `version --check` if you want zero external calls. @@ -145,7 +145,7 @@ v3 uses **HTTP transport exclusively**. stdio transport has been removed. 1. **Start the HTTP daemon** for each project: ```bash - limps start --config /path/to/.limps/config.json + limps server start --config /path/to/.limps/config.json ``` 2. **Update MCP client configs** — Replace stdio configs with HTTP transport: @@ -173,7 +173,7 @@ v3 removes the centralized project registry. If you previously used `limps confi **Removed commands:** `config list`, `config use`, `config add`, `config remove`, `config set`, `config discover`, `config migrate`, `config sync-mcp`, `serve`. -**Replaced by:** `limps init` + `limps start` + `limps config print`. +**Replaced by:** `limps init` + `limps server start` + `limps config print`. ## Project Setup @@ -199,7 +199,7 @@ If the directory contains a `plans/` subdirectory, limps uses it. Otherwise, it Each project has its own `.limps/config.json`. Use `--config` to target a specific project: ```bash -limps list-plans --config ~/docs/project-b/.limps/config.json +limps plan list --config ~/docs/project-b/.limps/config.json ``` ## Client Setup @@ -216,7 +216,7 @@ The output tells you exactly what JSON (or TOML) to add and where the config fil ### Per-Client Examples -All clients connect to the HTTP daemon. Start the daemon first with `limps start`, then configure your client. +All clients connect to the HTTP daemon. Start the daemon first with `limps server start`, then configure your client.
Cursor @@ -317,13 +317,13 @@ limps v3 uses **HTTP transport exclusively** via a persistent daemon. This allow ```bash # Start the daemon -limps start +limps server start # Check status (shows uptime, sessions, PID) -limps server-status +limps server status # Stop the daemon -limps stop +limps server stop ``` The daemon runs at `http://127.0.0.1:4269/mcp` by default. Use `limps config print` to generate the correct MCP client configuration: @@ -381,7 +381,7 @@ Example custom server config: - **Linux**: `$XDG_DATA_HOME/limps/pids/` or `~/.local/share/limps/pids/` - **Windows**: `%APPDATA%/limps/pids/` -This enables `limps server-status` to perform system-wide daemon discovery from any directory. When a limps config is found for the current directory (or passed via `--config`), the CLI also reports and reconciles that project's configured target. +This enables `limps server status` to perform system-wide daemon discovery from any directory. When a limps config is found for the current directory (or passed via `--config`), the CLI also reports and reconciles that project's configured target. - **Remote clients**: Use an MCP-compatible HTTPS proxy for remote clients (e.g., ChatGPT). @@ -423,7 +423,7 @@ Each PID file is named by port number (`limps-{port}.pid`) to enable system-wide } ``` -This port-based naming allows `limps server-status` to find all running daemons across different projects without needing a config file. +This port-based naming allows `limps server status` to find all running daemons across different projects without needing a config file. Daemon logs are written to OS-standard application log directories: @@ -452,7 +452,7 @@ Daemon log files are append-only and are not auto-rotated; if you run long-lived **Background mode (default):** ```bash -limps start +limps server start # → Daemon starts on http://127.0.0.1:4269/mcp # → PID file written to OS-standard location # → Logs written to OS-standard log file (append mode) @@ -462,7 +462,7 @@ limps start **Foreground mode (debugging):** ```bash -limps start --foreground +limps server start --foreground # → Runs in foreground (blocks terminal) # → Logs appear in stderr # → Useful for debugging startup issues @@ -485,14 +485,14 @@ Configure `server.port` and `server.host` in your `.limps/config.json`: Then start normally: ```bash -limps start +limps server start # → Starts using server.port/server.host from config # → PID file: limps-8080.pid ``` The `start` command performs health verification by polling the `/health` endpoint for up to 5 seconds, issuing repeated HTTP requests. Each individual health-check request has its own shorter timeout (for example, ~1000ms). If any request fails during this window, you'll see one of these error codes: -- **TIMEOUT** — A single health-check HTTP request exceeded its per-request timeout (e.g., ~1000ms). The daemon may be slow to start or system resources may be constrained. Try `limps start --foreground` to see logs. +- **TIMEOUT** — A single health-check HTTP request exceeded its per-request timeout (e.g., ~1000ms). The daemon may be slow to start or system resources may be constrained. Try `limps server start --foreground` to see logs. - **NETWORK_ERROR** — Cannot connect to daemon. Port may be blocked or already in use by another process. - **NON_200_STATUS** — Health endpoint returned a non-200 status code. Check daemon logs with foreground mode. - **INVALID_RESPONSE** — Health endpoint responded, but the response was invalid or could not be parsed as expected (for example, malformed or missing required fields). @@ -503,7 +503,7 @@ The `start` command performs health verification by polling the `/health` endpoi ```bash # From within a project directory with .limps/config.json -limps server-status +limps server status # Project target: # limps server is running # PID: 12345 | 127.0.0.1:4269 @@ -517,7 +517,7 @@ limps server-status # Log: /Users/you/Library/Application Support/limps/logs/limps-4269.log # Or specify config explicitly -limps server-status --config /path/to/.limps/config.json +limps server status --config /path/to/.limps/config.json ``` **Without project config (global discovery only):** @@ -525,7 +525,7 @@ limps server-status --config /path/to/.limps/config.json ```bash # From a directory without a limps config cd /tmp -limps server-status +limps server status # Found 2 running daemons: # 127.0.0.1:4269 (PID 12345) # Uptime: 2h 15m | Sessions: 3 @@ -535,13 +535,13 @@ limps server-status # Log: /Users/you/Library/Application Support/limps/logs/limps-8080.log ``` -When `limps server-status` cannot resolve a config file in the current directory (and no `--config` is provided), it reports global daemon discovery only. When a config is found, it reports both the configured project target and the global daemon list. +When `limps server status` cannot resolve a config file in the current directory (and no `--config` is provided), it reports global daemon discovery only. When a config is found, it reports both the configured project target and the global daemon list. ### Stopping the Daemon ```bash # From the project directory (where your .limps config lives): -limps stop +limps server stop # → Gracefully shuts down daemon # → Closes all MCP sessions # → Stops file watchers @@ -549,7 +549,7 @@ limps stop # → Process exits # Or from any directory, by specifying the config explicitly: -limps stop --config /path/to/.limps/config.json +limps server stop --config /path/to/.limps/config.json ``` The `stop` command is project-specific and resolves the config to determine which daemon to stop. The daemon performs a graceful shutdown by: @@ -563,13 +563,13 @@ The `stop` command is project-specific and resolves the config to determine whic If you try to start a daemon on a port that's already in use, limps will detect the conflict and provide resolution guidance: ```bash -limps start +limps server start # Error: Port 4269 is already in use. # Process using port: node (PID 12345) -# Command: /usr/local/bin/node /usr/local/bin/limps start +# Command: /usr/local/bin/node /usr/local/bin/limps server start # # To stop the process: kill 12345 -# Or use a different port: limps start --port +# Or use a different port: limps server start --port ``` On systems with `lsof` available (macOS, Linux), limps can identify which process is using the port and show its command line. If `lsof` is not available, you'll see a simpler error message suggesting a different port. @@ -579,7 +579,7 @@ On systems with `lsof` available (macOS, Linux), limps can identify which proces Use foreground mode for debugging, Docker deployments, or CI/CD pipelines: ```bash -limps start --foreground +limps server start --foreground ``` **Use cases:** @@ -630,13 +630,13 @@ You can run multiple limps daemons on different ports for different projects by # Project A with default port (4269) cd ~/projects/project-a # .limps/config.json has server.port: 4269 (or uses default) -limps start +limps server start # → Running on http://127.0.0.1:4269/mcp # Project B with custom port (8080) cd ~/projects/project-b # .limps/config.json has server.port: 8080 -limps start +limps server start # → Running on http://127.0.0.1:8080/mcp ``` @@ -648,7 +648,7 @@ Discover all running daemons (run from a directory without a limps config): ```bash cd /tmp -limps server-status +limps server status # Found 2 running daemons: # 127.0.0.1:4269 (PID 12345) # Uptime: 2h 15m | Sessions: 3 @@ -660,25 +660,30 @@ Each MCP client can connect to different daemons by configuring different URLs i ## CLI Commands -### Viewing Plans +### Recommended Grouped Commands ```bash -limps list-plans # List all plans with status -limps list-agents # List agents in a plan -limps status # Show plan progress summary -limps next-task # Get highest-priority available task +limps plan list # List all plans with status +limps plan agents # List agents in a plan +limps plan status # Show plan progress summary +limps plan next # Get highest-priority available task +limps plan score --plan --agent # Score a single task +limps plan scores --plan # Score all available tasks in a plan +limps docs list [path] # List files/directories +limps docs search # Search indexed docs +limps docs process [path] --code "" # Process docs with JavaScript +limps server start # Start HTTP daemon +limps server status # Show daemon status +limps server stop # Stop HTTP daemon ``` ### Project Management ```bash limps init [path] # Initialize new project -limps start # Start HTTP daemon (background by default) -limps start --foreground # Start in foreground (debugging mode) -limps stop # Stop HTTP daemon -limps server-status # Show daemon status (current project or all daemons) limps config show # Display current config limps config print # Print MCP client config snippets +limps completion zsh # Generate Zsh tab-completion script ``` ### Health & Automation @@ -710,9 +715,9 @@ limps graph watch # Watch and update incrementally ### Scoring & Repair ```bash -limps score-all # Score all agents in a plan -limps score-task # Score a single task -limps repair-plans [--fix] # Check/fix agent frontmatter +limps plan scores --plan # Score all agents in a plan +limps plan score --plan --agent # Score a single task +limps plan repair [--fix] # Check/fix agent frontmatter ``` ## Configuration @@ -755,7 +760,7 @@ Config lives at `.limps/config.json` in your project directory, created by `limp | Variable | Description | Example | | ---------------------- | ---------------------------------------------------------- | ------------------------------------------------- | -| `MCP_PLANNING_CONFIG` | Path to config file (overrides default discovery) | `MCP_PLANNING_CONFIG=./my-config.json limps serve`| +| `MCP_PLANNING_CONFIG` | Path to config file (overrides default discovery) | `MCP_PLANNING_CONFIG=./my-config.json limps server bridge`| | `LIMPS_ALLOWED_TOOLS` | Comma-separated allowlist; only these tools are registered | `LIMPS_ALLOWED_TOOLS="list_docs,search_docs"` | | `LIMPS_DISABLED_TOOLS` | Comma-separated denylist; tools to hide | `LIMPS_DISABLED_TOOLS="process_doc,process_docs"` | @@ -770,30 +775,30 @@ Config lives at `.limps/config.json` in your project directory, created by `limp If you see this error, another process is using the port: ```bash -limps start +limps server start # Error: Port 4269 is already in use. # Process using port: node (PID 12345) ``` **Resolution:** 1. **Kill the existing process**: `kill 12345` -2. **Or use a different port**: `limps start --port 8080` -3. **Check if it's another limps daemon**: `limps server-status` (if so, use `limps stop` first) +2. **Or use a different port**: `limps server start --port 8080` +3. **Check if it's another limps daemon**: `limps server status` (if so, use `limps server stop` first) **"Daemon may have failed to start" error:** If the daemon starts but doesn't respond to health checks: ```bash -limps start -# Error: Daemon may have failed to start. Check logs or try: limps start --foreground +limps server start +# Error: Daemon may have failed to start. Check logs or try: limps server start --foreground ``` **Resolution:** -1. **Check daemon log path**: `limps server-status` (or run foreground mode: `limps start --foreground`) +1. **Check daemon log path**: `limps server status` (or run foreground mode: `limps server start --foreground`) 2. **Check for permission issues**: Ensure you have write access to the PID directory 3. **Verify port is accessible**: Try `curl http://127.0.0.1:4269/health` -4. **Enable debug logging**: `DEBUG=1 limps start --foreground` +4. **Enable debug logging**: `DEBUG=1 limps server start --foreground` **Permission issues with PID directory:** @@ -827,7 +832,7 @@ mkdir %APPDATA%\limps\pids **TIMEOUT error:** -The daemon did not respond within the configured timeout. Each health-check request has its own timeout (for example, 1000ms during the final `limps start` check and 3000ms for `server-status`), and during startup limps will poll for up to about 5 seconds before reporting "Daemon may have failed to start". +The daemon did not respond within the configured timeout. Each health-check request has its own timeout (for example, 1000ms during the final `limps server start` check and 3000ms for `limps server status`), and during startup limps will poll for up to about 5 seconds before reporting "Daemon may have failed to start". **Common causes:** - System resource constraints (high CPU/memory usage) @@ -836,8 +841,8 @@ The daemon did not respond within the configured timeout. Each health-check requ **Resolution:** 1. Check system resources: `top` or Activity Monitor -2. Wait a bit longer and retry: `limps server-status` -3. Run in foreground to see progress: `limps start --foreground` +2. Wait a bit longer and retry: `limps server status` +3. Run in foreground to see progress: `limps server start --foreground` **NETWORK_ERROR:** @@ -849,17 +854,17 @@ Cannot establish connection to the daemon. - Incorrect host/port configuration **Resolution:** -1. Verify daemon is running: `limps server-status` +1. Verify daemon is running: `limps server status` 2. Check firewall settings for port 4269 3. Try `curl http://127.0.0.1:4269/health` manually -4. Check daemon logs: see `Log:` path in `limps server-status` output +4. Check daemon logs: see `Log:` path in `limps server status` output ### Stale PID Files limps automatically cleans up stale PID files when: -- Running `limps server-status` (discovers and removes stale files) -- Running `limps start` (removes stale file for the target port) -- The daemon shuts down gracefully with `limps stop` +- Running `limps server status` (discovers and removes stale files) +- Running `limps server start` (removes stale file for the target port) +- The daemon shuts down gracefully with `limps server stop` If you need to manually clean up PID files: @@ -876,7 +881,7 @@ del %APPDATA%\limps\pids\limps-*.pid **When to manually clean up:** - After a system crash or forced shutdown -- If `limps start` reports a daemon is running but it's not +- If `limps server start` reports a daemon is running but it's not - Before uninstalling limps ### Multiple Daemons Conflict @@ -884,16 +889,16 @@ del %APPDATA%\limps\pids\limps-*.pid If you accidentally try to start a second daemon on the same port: ```bash -limps start -# Error: limps daemon already running (PID 12345 on 127.0.0.1:4269). Run 'limps stop' first. +limps server start +# Error: limps daemon already running (PID 12345 on 127.0.0.1:4269). Run 'limps server stop' first. ``` This is expected behavior — limps prevents multiple daemons on the same port using PID-based locking. **Resolution:** -1. **Check all running daemons**: `limps server-status` -2. **Stop the existing daemon**: `limps stop` -3. **Or start on a different port**: `limps start --port 8080` +1. **Check all running daemons**: `limps server status` +2. **Stop the existing daemon**: `limps server stop` +3. **Or start on a different port**: `limps server start --port 8080` ### Debugging Connection Issues @@ -902,7 +907,7 @@ If MCP clients can't connect to the daemon, verify connectivity step by step: **1. Check daemon status:** ```bash -limps server-status +limps server status # Should show daemon running with healthy status ``` @@ -925,7 +930,7 @@ curl -X POST http://127.0.0.1:4269/mcp \ **4. Enable debug logging:** ```bash -DEBUG=1 limps start --foreground +DEBUG=1 limps server start --foreground # Watch for connection attempts and errors ``` @@ -995,7 +1000,7 @@ This shows all three priority levels for config resolution: ```bash # Override with explicit path -limps list-plans --config /path/to/project/.limps/config.json +limps plan list --config /path/to/project/.limps/config.json # Or set environment variable export MCP_PLANNING_CONFIG=/path/to/project/.limps/config.json diff --git a/packages/limps/package.json b/packages/limps/package.json index 0b84379..fd2692a 100644 --- a/packages/limps/package.json +++ b/packages/limps/package.json @@ -16,7 +16,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "build": "tsc", + "build": "rm -rf dist && tsc", "dev": "tsc --watch", "type-check": "tsc --noEmit", "lint": "eslint src/ tests/ --max-warnings 0", diff --git a/packages/limps/src/cli.tsx b/packages/limps/src/cli.tsx index 04faa22..947b21a 100644 --- a/packages/limps/src/cli.tsx +++ b/packages/limps/src/cli.tsx @@ -6,7 +6,9 @@ import { logRedactedError } from './utils/safe-logging.js'; // Check if running start command with --foreground flag — bypass Ink for clean stdio const args = process.argv.slice(2); -const isStartForeground = args[0] === 'start' && args.includes('--foreground'); +const isStartForeground = + args.includes('--foreground') && + ((args[0] === 'server' && args[1] === 'start') || args[0] === 'start'); const wantsHelp = args.includes('--help') || args.includes('-h') || (isStartForeground && args.includes('help')); diff --git a/packages/limps/src/cli/config-cmd.ts b/packages/limps/src/cli/config-cmd.ts index df83ecf..b8be61d 100644 --- a/packages/limps/src/cli/config-cmd.ts +++ b/packages/limps/src/cli/config-cmd.ts @@ -512,7 +512,7 @@ export function generateChatGptInstructions(configPath: string): string { lines.push(` Server Name: ${serverName}`); lines.push(' Server URL: https://your-tunnel-domain.example/mcp'); lines.push(' Authentication: (required by your proxy/deployment)'); - lines.push(` Local command: limps start --config ${configPath}`); + lines.push(` Local command: limps server start --config ${configPath}`); lines.push(''); lines.push('Tip: create one connector per project config.'); diff --git a/packages/limps/src/cli/init-project.ts b/packages/limps/src/cli/init-project.ts index 0deef83..dd47553 100644 --- a/packages/limps/src/cli/init-project.ts +++ b/packages/limps/src/cli/init-project.ts @@ -109,13 +109,13 @@ export function initProject(targetPath = '.'): string { // Daemon management lines.push('Manage the daemon:'); - lines.push(` ${localPlannerPath} server-status --config "${configPath}" # Check status`); - lines.push(` ${localPlannerPath} stop --config "${configPath}" # Stop daemon`); + lines.push(` ${localPlannerPath} server status --config "${configPath}" # Check status`); + lines.push(` ${localPlannerPath} server stop --config "${configPath}" # Stop daemon`); lines.push(''); // CLI usage lines.push('Use --config for CLI commands:'); - lines.push(` ${localPlannerPath} list-plans --config "${configPath}"`); + lines.push(` ${localPlannerPath} plan list --config "${configPath}"`); return lines.join('\n'); } diff --git a/packages/limps/src/cli/next-task.ts b/packages/limps/src/cli/next-task.ts index 77c7b87..8806eee 100644 --- a/packages/limps/src/cli/next-task.ts +++ b/packages/limps/src/cli/next-task.ts @@ -146,7 +146,7 @@ function getPlanScoringOverrides( !hasAnyOverrides ) { console.warn( - `Malformed frontmatter in plan file: ${planFilePath}. Run \`limps repair-plans --check --json\` for a structured report.` + `Malformed frontmatter in plan file: ${planFilePath}. Run \`limps plan repair --check --json\` for a structured report.` ); } diff --git a/packages/limps/src/commands/__complete.tsx b/packages/limps/src/commands/__complete.tsx new file mode 100644 index 0000000..8af58ae --- /dev/null +++ b/packages/limps/src/commands/__complete.tsx @@ -0,0 +1,22 @@ +import { Text } from 'ink'; +import { z } from 'zod'; +import { getCompletionSuggestions } from '../core/completion.js'; + +export const description = 'Internal completion endpoint'; + +export const args = z.array(z.string()); + +export const options = z.object({ + config: z.string().optional().describe('Path to config file'), +}); + +interface Props { + args: z.infer; + options: z.infer; +} + +export default function InternalCompleteCommand({ args, options }: Props): React.ReactNode { + const tokens = args[0] === '--' ? args.slice(1) : args; + const suggestions = getCompletionSuggestions(tokens, { configPath: options.config }); + return {suggestions.join('\n')}; +} diff --git a/packages/limps/src/commands/completion.tsx b/packages/limps/src/commands/completion.tsx new file mode 100644 index 0000000..e9f596b --- /dev/null +++ b/packages/limps/src/commands/completion.tsx @@ -0,0 +1,34 @@ +import { Text } from 'ink'; +import { z } from 'zod'; +import { buildHelpOutput } from '../utils/cli-help.js'; +import { getCompletionScript } from '../core/completion.js'; + +export const description = 'Generate shell completion scripts (bash, zsh, fish)'; + +export const args = z.tuple([z.enum(['bash', 'zsh', 'fish']).optional()]); + +interface Props { + args: z.infer; +} + +export default function CompletionCommand({ args }: Props): React.ReactNode { + const [shell] = args; + const help = buildHelpOutput({ + usage: 'limps completion ', + examples: [ + 'limps completion zsh >> ~/.zshrc', + 'limps completion bash >> ~/.bashrc', + 'limps completion fish > ~/.config/fish/completions/limps.fish', + ], + tips: [ + 'After writing completion config, open a new shell session.', + 'Use tab after `limps `, `limps plan `, and `limps plan score --plan ` for suggestions.', + ], + }); + + if (!shell) { + return {help.text}; + } + + return {getCompletionScript(shell)}; +} diff --git a/packages/limps/src/commands/create-doc.tsx b/packages/limps/src/commands/docs/create.tsx similarity index 81% rename from packages/limps/src/commands/create-doc.tsx rename to packages/limps/src/commands/docs/create.tsx index 178bbde..b912de3 100644 --- a/packages/limps/src/commands/create-doc.tsx +++ b/packages/limps/src/commands/docs/create.tsx @@ -1,11 +1,10 @@ import { Text } from 'ink'; import { useEffect, useState } from 'react'; import { z } from 'zod'; -import { createDoc, getCreateDocData } from '../cli/docs-create.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { createDoc, getCreateDocData } from '../../cli/docs-create.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; export const description = 'Create a new document'; @@ -29,7 +28,7 @@ interface Props { export default function CreateDocCommand({ args, options }: Props): React.ReactNode { const [path] = args; const help = buildHelpOutput({ - usage: 'limps create-doc [options]', + usage: 'limps docs create [options]', arguments: ['path Path for new document (relative to repo root)'], options: [ '--config Path to config file', @@ -38,8 +37,8 @@ export default function CreateDocCommand({ args, options }: Props): React.ReactN '--json Output as JSON', ], examples: [ - 'limps create-doc "research/notes.md" --content "# Notes"', - 'limps create-doc "addendum.md" --template addendum --content "Details..."', + 'limps docs create "research/notes.md" --content "# Notes"', + 'limps docs create "addendum.md" --template addendum --content "Details..."', ], }); @@ -60,8 +59,7 @@ export default function CreateDocCommand({ args, options }: Props): React.ReactN return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await getCreateDocData(config, { path, @@ -102,8 +100,7 @@ export default function CreateDocCommand({ args, options }: Props): React.ReactN useEffect(() => { (async (): Promise => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await createDoc(config, { path, content: options.content || '', diff --git a/packages/limps/src/commands/delete-doc.tsx b/packages/limps/src/commands/docs/delete.tsx similarity index 81% rename from packages/limps/src/commands/delete-doc.tsx rename to packages/limps/src/commands/docs/delete.tsx index b51a146..3935c2e 100644 --- a/packages/limps/src/commands/delete-doc.tsx +++ b/packages/limps/src/commands/docs/delete.tsx @@ -1,11 +1,10 @@ import { Text } from 'ink'; import { useEffect, useState } from 'react'; import { z } from 'zod'; -import { deleteDoc, getDeleteDocData } from '../cli/docs-delete.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { deleteDoc, getDeleteDocData } from '../../cli/docs-delete.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; export const description = 'Delete a document'; @@ -26,7 +25,7 @@ interface Props { export default function DeleteDocCommand({ args, options }: Props): React.ReactNode { const [path] = args; const help = buildHelpOutput({ - usage: 'limps delete-doc [options]', + usage: 'limps docs delete [options]', arguments: ['path Path to document to delete'], options: [ '--config Path to config file', @@ -35,9 +34,9 @@ export default function DeleteDocCommand({ args, options }: Props): React.ReactN '--json Output as JSON', ], examples: [ - 'limps delete-doc "notes.md"', - 'limps delete-doc "notes.md" --confirm', - 'limps delete-doc "old-file.md" --confirm --permanent', + 'limps docs delete "notes.md"', + 'limps docs delete "notes.md" --confirm', + 'limps docs delete "old-file.md" --confirm --permanent', ], }); @@ -58,8 +57,7 @@ export default function DeleteDocCommand({ args, options }: Props): React.ReactN return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await getDeleteDocData(config, { path, @@ -100,8 +98,7 @@ export default function DeleteDocCommand({ args, options }: Props): React.ReactN useEffect(() => { (async (): Promise => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await deleteDoc(config, { path, confirm: options.confirm, diff --git a/packages/limps/src/commands/docs/index.tsx b/packages/limps/src/commands/docs/index.tsx new file mode 100644 index 0000000..657392b --- /dev/null +++ b/packages/limps/src/commands/docs/index.tsx @@ -0,0 +1,40 @@ +import { Text } from 'ink'; +import { buildHelpOutput } from '../../utils/cli-help.js'; + +export const description = 'Document workflows (create/search/update/process)'; + +export default function DocsCommand(): React.ReactNode { + const help = buildHelpOutput({ + usage: 'limps docs ', + sections: [ + { + title: 'Commands', + lines: [ + 'list [path] List files/directories', + 'search Full-text search documents', + 'create Create a document', + 'update Update a document', + 'delete Delete a document', + 'process [path] Run JavaScript processing against docs', + 'tags Manage tags (list/add/remove)', + 'reindex Rebuild the search index', + ], + }, + { + title: 'Examples', + lines: [ + 'limps docs list plans', + 'limps docs search "retry policy"', + 'limps docs process plans/0004/plan.md --code "doc.content"', + 'limps docs tags add plans/0004/plan.md --tags reviewed', + ], + }, + { + title: 'Help', + lines: ['Run `limps docs --help` for options and examples.'], + }, + ], + }); + + return {help.text}; +} diff --git a/packages/limps/src/commands/list-docs.tsx b/packages/limps/src/commands/docs/list.tsx similarity index 86% rename from packages/limps/src/commands/list-docs.tsx rename to packages/limps/src/commands/docs/list.tsx index 40b57d6..5a13cb7 100644 --- a/packages/limps/src/commands/list-docs.tsx +++ b/packages/limps/src/commands/docs/list.tsx @@ -1,10 +1,9 @@ import { Text } from 'ink'; import React, { useEffect, useState } from 'react'; import { z } from 'zod'; -import { listDocs, getDocsListData } from '../cli/docs-list.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { listDocs, getDocsListData } from '../../cli/docs-list.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; export const description = 'List files and directories'; @@ -38,8 +37,7 @@ export default function ListDocsCommand({ args, options }: Props): React.ReactNo const timer = setTimeout((): void => { (async (): Promise => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await getDocsListData(config, { path, @@ -79,8 +77,7 @@ export default function ListDocsCommand({ args, options }: Props): React.ReactNo useEffect(() => { (async (): Promise => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await listDocs(config, { path, pattern: options.pattern, diff --git a/packages/limps/src/commands/process.tsx b/packages/limps/src/commands/docs/process.tsx similarity index 90% rename from packages/limps/src/commands/process.tsx rename to packages/limps/src/commands/docs/process.tsx index a7280ef..887c91f 100644 --- a/packages/limps/src/commands/process.tsx +++ b/packages/limps/src/commands/docs/process.tsx @@ -1,24 +1,23 @@ import React, { useEffect, useState } from 'react'; import { Text, Box } from 'ink'; import { z } from 'zod'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; import { handleProcessDoc, type ProcessDocOutput, ProcessDocInputSchema, -} from '../tools/process-doc.js'; +} from '../../tools/process-doc.js'; import { handleProcessDocs, type ProcessDocsOutput, ProcessDocsInputSchema, -} from '../tools/process-docs.js'; -import { initializeDatabase, createSchema } from '../indexer.js'; +} from '../../tools/process-docs.js'; +import { initializeDatabase, createSchema } from '../../indexer.js'; import { join } from 'path'; import { mkdirSync } from 'fs'; -import type { ToolContext } from '../types.js'; +import type { ToolContext } from '../../types.js'; export const description = 'Process document(s) with JavaScript code'; @@ -48,7 +47,8 @@ export default function ProcessCommand({ args, options }: Props): React.ReactNod const [error, setError] = useState(null); const help = buildHelpOutput({ - usage: 'limps process | limps process --pattern --code [options]', + usage: + 'limps docs process | limps docs process --pattern --code [options]', arguments: ['path Optional document path (mutually exclusive with --pattern)'], options: [ '--config Path to config file', @@ -63,15 +63,15 @@ export default function ProcessCommand({ args, options }: Props): React.ReactNod { title: 'Single Document Examples', lines: [ - 'limps process plans/0001-feature/plan.md --code "doc.content.length"', - 'limps process plan.md --code "extractFrontmatter(doc.content).meta.name"', + 'limps docs process plans/0001-feature/plan.md --code "doc.content.length"', + 'limps docs process plan.md --code "extractFrontmatter(doc.content).meta.name"', ], }, { title: 'Multiple Documents Examples', lines: [ - 'limps process --pattern "plans/*/*-plan.md" --code "docs.length"', - 'limps process --pattern "**/*.md" --code "docs.map(d => d.path)"', + 'limps docs process --pattern "plans/*/*-plan.md" --code "docs.length"', + 'limps docs process --pattern "**/*.md" --code "docs.map(d => d.path)"', ], }, ], @@ -122,8 +122,7 @@ export default function ProcessCommand({ args, options }: Props): React.ReactNod return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); // Initialize database mkdirSync(config.dataPath, { recursive: true }); @@ -223,8 +222,7 @@ export default function ProcessCommand({ args, options }: Props): React.ReactNod return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); // Initialize database mkdirSync(config.dataPath, { recursive: true }); diff --git a/packages/limps/src/commands/reindex.tsx b/packages/limps/src/commands/docs/reindex.tsx similarity index 89% rename from packages/limps/src/commands/reindex.tsx rename to packages/limps/src/commands/docs/reindex.tsx index 9023dfa..4ca26be 100644 --- a/packages/limps/src/commands/reindex.tsx +++ b/packages/limps/src/commands/docs/reindex.tsx @@ -3,9 +3,9 @@ import { useEffect, useState } from 'react'; import { z } from 'zod'; import { resolve } from 'path'; import { mkdirSync } from 'fs'; -import { loadConfig, getAllDocsPaths, getFileExtensions } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { initializeDatabase, createSchema, clearIndex, indexAllPaths } from '../indexer.js'; +import { getAllDocsPaths, getFileExtensions } from '../../config.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { initializeDatabase, createSchema, clearIndex, indexAllPaths } from '../../indexer.js'; export const description = 'Clear and rebuild the document search index'; @@ -32,8 +32,7 @@ export default function ReindexCommand({ options }: Props): React.ReactNode { const run = async (): Promise => { let db: ReturnType | null = null; try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); mkdirSync(config.dataPath, { recursive: true }); const dbPath = resolve(config.dataPath, 'documents.sqlite'); db = initializeDatabase(dbPath); diff --git a/packages/limps/src/commands/search-docs.tsx b/packages/limps/src/commands/docs/search.tsx similarity index 82% rename from packages/limps/src/commands/search-docs.tsx rename to packages/limps/src/commands/docs/search.tsx index b38a68e..2a71a38 100644 --- a/packages/limps/src/commands/search-docs.tsx +++ b/packages/limps/src/commands/docs/search.tsx @@ -1,11 +1,10 @@ import { Text } from 'ink'; import { useEffect, useState } from 'react'; import { z } from 'zod'; -import { searchDocs, getSearchDocsData } from '../cli/docs-search.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { searchDocs, getSearchDocsData } from '../../cli/docs-search.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; export const description = 'Search documents using full-text search'; @@ -27,7 +26,7 @@ interface Props { export default function SearchDocsCommand({ args, options }: Props): React.ReactNode { const [query] = args; const help = buildHelpOutput({ - usage: 'limps search-docs [options]', + usage: 'limps docs search [options]', arguments: ['query Search query text'], options: [ '--config Path to config file', @@ -37,9 +36,9 @@ export default function SearchDocsCommand({ args, options }: Props): React.React '--json Output as JSON', ], examples: [ - 'limps search-docs "authentication"', - 'limps search-docs "API" --limit 10', - 'limps search-docs "status" --frontmatter', + 'limps docs search "authentication"', + 'limps docs search "API" --limit 10', + 'limps docs search "status" --frontmatter', ], }); @@ -60,8 +59,7 @@ export default function SearchDocsCommand({ args, options }: Props): React.React return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await getSearchDocsData(config, { query, @@ -111,8 +109,7 @@ export default function SearchDocsCommand({ args, options }: Props): React.React useEffect(() => { (async (): Promise => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = await searchDocs(config, { query, limit: options.limit, diff --git a/packages/limps/src/commands/tags/add.tsx b/packages/limps/src/commands/docs/tags/add.tsx similarity index 82% rename from packages/limps/src/commands/tags/add.tsx rename to packages/limps/src/commands/docs/tags/add.tsx index 98eec0d..81e28bc 100644 --- a/packages/limps/src/commands/tags/add.tsx +++ b/packages/limps/src/commands/docs/tags/add.tsx @@ -2,13 +2,12 @@ import { Text } from 'ink'; import React, { useEffect } from 'react'; import { join } from 'path'; import { z } from 'zod'; -import { handleManageTags } from '../../tools/manage-tags.js'; -import { loadConfig } from '../../config.js'; -import { resolveConfigPath } from '../../utils/config-resolver.js'; -import { initializeDatabase, createSchema } from '../../indexer.js'; -import { buildHelpOutput } from '../../utils/cli-help.js'; -import { isJsonMode, outputJson, wrapError, wrapSuccess } from '../../cli/json-output.js'; -import type { ManageTagsOutput } from '../../tools/manage-tags.js'; +import { handleManageTags } from '../../../tools/manage-tags.js'; +import { loadCommandContext } from '../../../core/command-context.js'; +import { initializeDatabase, createSchema } from '../../../indexer.js'; +import { buildHelpOutput } from '../../../utils/cli-help.js'; +import { isJsonMode, outputJson, wrapError, wrapSuccess } from '../../../cli/json-output.js'; +import type { ManageTagsOutput } from '../../../tools/manage-tags.js'; export const description = 'Add tags to a document'; @@ -30,7 +29,7 @@ export default function TagsAddCommand({ args, options }: Props): React.ReactNod // Get tags from options or from remaining args const tags = options.tags || args.slice(1); const help = buildHelpOutput({ - usage: 'limps tags add [options]', + usage: 'limps docs tags add [options]', arguments: ['path Path to the document'], options: [ '--config Path to config file', @@ -38,9 +37,9 @@ export default function TagsAddCommand({ args, options }: Props): React.ReactNod '--tags Tags to add (space-separated)', ], examples: [ - 'limps tags add plans/0001-feature/000-agent.md --tags reviewed', - 'limps tags add plans/0001-feature/000-agent.md --tags urgent high-priority', - 'limps tags add plans/0001-feature/000-agent.md --tags reviewed --json', + 'limps docs tags add plans/0001-feature/000-agent.md --tags reviewed', + 'limps docs tags add plans/0001-feature/000-agent.md --tags urgent high-priority', + 'limps docs tags add plans/0001-feature/000-agent.md --tags reviewed --json', ], }); @@ -67,8 +66,7 @@ export default function TagsAddCommand({ args, options }: Props): React.ReactNod return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const dbPath = join(config.dataPath, 'documents.sqlite'); const db = initializeDatabase(dbPath); createSchema(db); @@ -109,8 +107,7 @@ export default function TagsAddCommand({ args, options }: Props): React.ReactNod return {help.text}; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); diff --git a/packages/limps/src/commands/tags/index.tsx b/packages/limps/src/commands/docs/tags/index.tsx similarity index 59% rename from packages/limps/src/commands/tags/index.tsx rename to packages/limps/src/commands/docs/tags/index.tsx index 1217d76..f4b4e86 100644 --- a/packages/limps/src/commands/tags/index.tsx +++ b/packages/limps/src/commands/docs/tags/index.tsx @@ -1,11 +1,11 @@ import { Text } from 'ink'; -import { buildHelpOutput } from '../../utils/cli-help.js'; +import { buildHelpOutput } from '../../../utils/cli-help.js'; export const description = 'Manage document tags'; export default function TagsCommand(): React.ReactNode { const help = buildHelpOutput({ - usage: 'limps tags ', + usage: 'limps docs tags ', sections: [ { title: 'Commands', @@ -18,14 +18,14 @@ export default function TagsCommand(): React.ReactNode { { title: 'Examples', lines: [ - 'limps tags list plans/0001-feature/000-agent.md', - 'limps tags add plans/0001-feature/000-agent.md --tags reviewed urgent', - 'limps tags remove plans/0001-feature/000-agent.md --tags urgent', + 'limps docs tags list plans/0001-feature/000-agent.md', + 'limps docs tags add plans/0001-feature/000-agent.md --tags reviewed urgent', + 'limps docs tags remove plans/0001-feature/000-agent.md --tags urgent', ], }, { title: 'Help', - lines: ['Run `limps tags --help` for more information.'], + lines: ['Run `limps docs tags --help` for more information.'], }, ], }); diff --git a/packages/limps/src/commands/tags/list.tsx b/packages/limps/src/commands/docs/tags/list.tsx similarity index 82% rename from packages/limps/src/commands/tags/list.tsx rename to packages/limps/src/commands/docs/tags/list.tsx index 85e6d80..2577c37 100644 --- a/packages/limps/src/commands/tags/list.tsx +++ b/packages/limps/src/commands/docs/tags/list.tsx @@ -2,13 +2,12 @@ import { Text } from 'ink'; import React, { useEffect } from 'react'; import { join } from 'path'; import { z } from 'zod'; -import { handleManageTags } from '../../tools/manage-tags.js'; -import { loadConfig } from '../../config.js'; -import { resolveConfigPath } from '../../utils/config-resolver.js'; -import { initializeDatabase, createSchema } from '../../indexer.js'; -import { buildHelpOutput } from '../../utils/cli-help.js'; -import { isJsonMode, outputJson, wrapError, wrapSuccess } from '../../cli/json-output.js'; -import type { ManageTagsOutput } from '../../tools/manage-tags.js'; +import { handleManageTags } from '../../../tools/manage-tags.js'; +import { loadCommandContext } from '../../../core/command-context.js'; +import { initializeDatabase, createSchema } from '../../../indexer.js'; +import { buildHelpOutput } from '../../../utils/cli-help.js'; +import { isJsonMode, outputJson, wrapError, wrapSuccess } from '../../../cli/json-output.js'; +import type { ManageTagsOutput } from '../../../tools/manage-tags.js'; export const description = 'List tags in a document'; @@ -27,12 +26,12 @@ interface Props { export default function TagsListCommand({ args, options }: Props): React.ReactNode { const [path] = args; const help = buildHelpOutput({ - usage: 'limps tags list [options]', + usage: 'limps docs tags list [options]', arguments: ['path Path to the document'], options: ['--config Path to config file', '--json Output as JSON'], examples: [ - 'limps tags list plans/0001-feature/000-agent.md', - 'limps tags list plans/0001-feature/000-agent.md --json', + 'limps docs tags list plans/0001-feature/000-agent.md', + 'limps docs tags list plans/0001-feature/000-agent.md --json', ], }); @@ -51,8 +50,7 @@ export default function TagsListCommand({ args, options }: Props): React.ReactNo return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const dbPath = join(config.dataPath, 'documents.sqlite'); const db = initializeDatabase(dbPath); createSchema(db); @@ -93,8 +91,7 @@ export default function TagsListCommand({ args, options }: Props): React.ReactNo return {help.text}; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); diff --git a/packages/limps/src/commands/tags/remove.tsx b/packages/limps/src/commands/docs/tags/remove.tsx similarity index 82% rename from packages/limps/src/commands/tags/remove.tsx rename to packages/limps/src/commands/docs/tags/remove.tsx index 48245a7..a3b502f 100644 --- a/packages/limps/src/commands/tags/remove.tsx +++ b/packages/limps/src/commands/docs/tags/remove.tsx @@ -2,13 +2,12 @@ import { Text } from 'ink'; import React, { useEffect } from 'react'; import { join } from 'path'; import { z } from 'zod'; -import { handleManageTags } from '../../tools/manage-tags.js'; -import { loadConfig } from '../../config.js'; -import { resolveConfigPath } from '../../utils/config-resolver.js'; -import { initializeDatabase, createSchema } from '../../indexer.js'; -import { buildHelpOutput } from '../../utils/cli-help.js'; -import { isJsonMode, outputJson, wrapError, wrapSuccess } from '../../cli/json-output.js'; -import type { ManageTagsOutput } from '../../tools/manage-tags.js'; +import { handleManageTags } from '../../../tools/manage-tags.js'; +import { loadCommandContext } from '../../../core/command-context.js'; +import { initializeDatabase, createSchema } from '../../../indexer.js'; +import { buildHelpOutput } from '../../../utils/cli-help.js'; +import { isJsonMode, outputJson, wrapError, wrapSuccess } from '../../../cli/json-output.js'; +import type { ManageTagsOutput } from '../../../tools/manage-tags.js'; export const description = 'Remove tags from a document'; @@ -30,7 +29,7 @@ export default function TagsRemoveCommand({ args, options }: Props): React.React // Get tags from options or from remaining args const tags = options.tags || args.slice(1); const help = buildHelpOutput({ - usage: 'limps tags remove [options]', + usage: 'limps docs tags remove [options]', arguments: ['path Path to the document'], options: [ '--config Path to config file', @@ -38,9 +37,9 @@ export default function TagsRemoveCommand({ args, options }: Props): React.React '--tags Tags to remove (space-separated)', ], examples: [ - 'limps tags remove plans/0001-feature/000-agent.md --tags urgent', - 'limps tags remove plans/0001-feature/000-agent.md --tags urgent high-priority', - 'limps tags remove plans/0001-feature/000-agent.md --tags urgent --json', + 'limps docs tags remove plans/0001-feature/000-agent.md --tags urgent', + 'limps docs tags remove plans/0001-feature/000-agent.md --tags urgent high-priority', + 'limps docs tags remove plans/0001-feature/000-agent.md --tags urgent --json', ], }); @@ -67,8 +66,7 @@ export default function TagsRemoveCommand({ args, options }: Props): React.React return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const dbPath = join(config.dataPath, 'documents.sqlite'); const db = initializeDatabase(dbPath); createSchema(db); @@ -109,8 +107,7 @@ export default function TagsRemoveCommand({ args, options }: Props): React.React return {help.text}; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); diff --git a/packages/limps/src/commands/update-doc.tsx b/packages/limps/src/commands/docs/update.tsx similarity index 85% rename from packages/limps/src/commands/update-doc.tsx rename to packages/limps/src/commands/docs/update.tsx index 6ffe009..a876b11 100644 --- a/packages/limps/src/commands/update-doc.tsx +++ b/packages/limps/src/commands/docs/update.tsx @@ -1,11 +1,10 @@ import { Text } from 'ink'; import { useEffect, useState } from 'react'; import { z } from 'zod'; -import { updateDoc, getUpdateDocData } from '../cli/docs-update.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { updateDoc, getUpdateDocData } from '../../cli/docs-update.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; export const description = 'Update an existing document'; @@ -32,7 +31,7 @@ interface Props { export default function UpdateDocCommand({ args, options }: Props): React.ReactNode { const [path] = args; const help = buildHelpOutput({ - usage: 'limps update-doc [options]', + usage: 'limps docs update [options]', arguments: ['path Path to existing document'], options: [ '--config Path to config file', @@ -44,9 +43,9 @@ export default function UpdateDocCommand({ args, options }: Props): React.ReactN '--json Output as JSON', ], examples: [ - 'limps update-doc "notes.md" --content "New content" --mode overwrite', - 'limps update-doc "notes.md" --content "More notes" --mode append', - 'limps update-doc "notes.md" --patch "old text:::new text"', + 'limps docs update "notes.md" --content "New content" --mode overwrite', + 'limps docs update "notes.md" --content "More notes" --mode append', + 'limps docs update "notes.md" --patch "old text:::new text"', ], }); @@ -67,8 +66,7 @@ export default function UpdateDocCommand({ args, options }: Props): React.ReactN return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); let patchObj: { search: string; replace: string } | undefined; if (options.patch) { @@ -130,8 +128,7 @@ export default function UpdateDocCommand({ args, options }: Props): React.ReactN useEffect(() => { (async (): Promise => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); let patchObj: { search: string; replace: string } | undefined; if (options.patch) { diff --git a/packages/limps/src/commands/index.tsx b/packages/limps/src/commands/index.tsx index 6e3a1b2..4939c24 100644 --- a/packages/limps/src/commands/index.tsx +++ b/packages/limps/src/commands/index.tsx @@ -24,36 +24,34 @@ export default function DefaultCommand(): React.ReactNode { {' '} v{version} - Local Intelligent MCP Planning Server{'\n'} {'\n'} - Usage: limps {''} [options]{'\n'} + Usage: limps {''} [options]{'\n'} {'\n'} - HTTP Server: + Recommended Groups: {'\n'} - {' '}start{' '}Start the HTTP daemon{'\n'} - {' '}stop{' '}Stop the HTTP daemon{'\n'} - {' '}server-status{' '}Show daemon status{'\n'} + {' '}server{' '}Daemon lifecycle (start/stop/status/bridge){'\n'} + {' '}plan{' '}Plan and task workflows{'\n'} + {' '}docs{' '}Document CRUD/search/process/tags{'\n'} + {' '}config{' '}Project configuration{'\n'} + {' '}health{' '}Health checks and inference{'\n'} + {' '}graph{' '}Knowledge graph operations{'\n'} + {' '}proposals{' '}Review and apply suggested fixes{'\n'} {'\n'} - Plan Management: + Common Commands: {'\n'} {' '}init {' '}Initialize a new project{'\n'} - {' '}list-plans{' '}List all plans with status{'\n'} - {' '}list-agents{' '}List agents in a plan{'\n'} - {' '}next-task{' '}Get the next best task{'\n'} - {' '}score-task{' '}Show scoring for a task{'\n'} - {' '}score-all{' '}Compare scores across tasks{'\n'} - {' '}status{' '}Show plan progress{'\n'} - {' '}repair-plans{' '}Repair plan frontmatter{'\n'} - {' '}health{' '}Run plan health checks{'\n'} - {' '}proposals{' '}List and apply update proposals{'\n'} + {' '}plan list{' '}List plans with status{'\n'} + {' '}plan agents 4{' '}List agents in plan 4{'\n'} + {' '}plan score --plan 4 --agent 3{' '}Score a specific task{'\n'} + {' '}plan scores --plan 4{' '}Compare all available task scores{'\n'} + {' '}docs search "query"{' '}Search indexed documents{'\n'} + {' '}completion zsh Enable shell tab completion{'\n'} {'\n'} - System: + Other: {'\n'} - {' '}reindex{' '}Rebuild the document index{'\n'} - {' '}config{' '}Manage project configuration{'\n'} - {' '}graph{' '}Knowledge graph commands{'\n'} {' '}version{' '}Show version information{'\n'} {'\n'} - Run limps {''} without args for usage help.{'\n'} - Run limps {''} --help for full documentation. + Run limps {''} without args for guided help.{'\n'} + Run limps {''} --help for full documentation. ); diff --git a/packages/limps/src/commands/list-agents.tsx b/packages/limps/src/commands/plan/agents.tsx similarity index 77% rename from packages/limps/src/commands/list-agents.tsx rename to packages/limps/src/commands/plan/agents.tsx index 346c134..9ff4c67 100644 --- a/packages/limps/src/commands/list-agents.tsx +++ b/packages/limps/src/commands/plan/agents.tsx @@ -1,12 +1,11 @@ import { Text } from 'ink'; import { useEffect } from 'react'; import { z } from 'zod'; -import { getAgentsData } from '../cli/list-agents.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { AgentsList } from '../components/AgentsList.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { getAgentsData } from '../../cli/list-agents.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { AgentsList } from '../../components/AgentsList.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; export const description = 'List agents in a plan'; @@ -25,13 +24,13 @@ interface Props { export default function ListAgentsCommand({ args, options }: Props): React.ReactNode { const [planId] = args; const help = buildHelpOutput({ - usage: 'limps list-agents [options]', + usage: 'limps plan agents [options]', arguments: ['plan Plan ID or name (e.g., "4" or "0004-feature-name")'], options: ['--config Path to config file', '--json Output as JSON'], examples: [ - 'limps list-agents 4', - 'limps list-agents 0004-my-feature', - 'limps list-agents 4 --json', + 'limps plan agents 4', + 'limps plan agents 0004-my-feature', + 'limps plan agents 4 --json', ], }); @@ -48,8 +47,7 @@ export default function ListAgentsCommand({ args, options }: Props): React.React 1 ); } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); handleJsonOutput(() => { const result = getAgentsData(config, planId); if ('error' in result) { @@ -78,8 +76,7 @@ export default function ListAgentsCommand({ args, options }: Props): React.React } try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = getAgentsData(config, planId); if ('error' in result) { diff --git a/packages/limps/src/commands/create-plan.tsx b/packages/limps/src/commands/plan/create.tsx similarity index 84% rename from packages/limps/src/commands/create-plan.tsx rename to packages/limps/src/commands/plan/create.tsx index c401241..3d51995 100644 --- a/packages/limps/src/commands/create-plan.tsx +++ b/packages/limps/src/commands/plan/create.tsx @@ -3,11 +3,10 @@ import { useEffect, useState } from 'react'; import { z } from 'zod'; import { resolve } from 'path'; import { mkdirSync } from 'fs'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { initializeDatabase, createSchema } from '../indexer.js'; -import { handleCreatePlan, CreatePlanInputSchema } from '../tools/create-plan.js'; -import type { ToolContext } from '../types.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { initializeDatabase, createSchema } from '../../indexer.js'; +import { handleCreatePlan, CreatePlanInputSchema } from '../../tools/create-plan.js'; +import type { ToolContext } from '../../types.js'; export const description = 'Create a new plan with directory structure'; @@ -39,8 +38,7 @@ export default function CreatePlanCommand({ args, options }: Props): React.React let db: ReturnType | null = null; try { const [name] = args; - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); mkdirSync(config.dataPath, { recursive: true }); const dbPath = resolve(config.dataPath, 'documents.sqlite'); db = initializeDatabase(dbPath); diff --git a/packages/limps/src/commands/plan/index.tsx b/packages/limps/src/commands/plan/index.tsx new file mode 100644 index 0000000..7bde533 --- /dev/null +++ b/packages/limps/src/commands/plan/index.tsx @@ -0,0 +1,40 @@ +import { Text } from 'ink'; +import { buildHelpOutput } from '../../utils/cli-help.js'; + +export const description = 'Plan and task workflows (recommended command group)'; + +export default function PlanCommand(): React.ReactNode { + const help = buildHelpOutput({ + usage: 'limps plan ', + sections: [ + { + title: 'Commands', + lines: [ + 'create Create a new plan', + 'list List all plans', + 'agents List agents in a plan', + 'status Show plan progress', + 'next Get next best task', + 'score --plan

--agent Score a specific task', + 'scores --plan

Score all available tasks in a plan', + 'repair [plan] Check/fix plan frontmatter issues', + ], + }, + { + title: 'Examples', + lines: [ + 'limps plan list', + 'limps plan agents 4', + 'limps plan score --plan 4 --agent 3', + 'limps plan scores --plan 4 --json', + ], + }, + { + title: 'Help', + lines: ['Run `limps plan --help` for more details and examples.'], + }, + ], + }); + + return {help.text}; +} diff --git a/packages/limps/src/commands/list-plans.tsx b/packages/limps/src/commands/plan/list.tsx similarity index 77% rename from packages/limps/src/commands/list-plans.tsx rename to packages/limps/src/commands/plan/list.tsx index b513bce..63ca4e8 100644 --- a/packages/limps/src/commands/list-plans.tsx +++ b/packages/limps/src/commands/plan/list.tsx @@ -1,11 +1,10 @@ import { Box, Text } from 'ink'; import { useEffect } from 'react'; import { z } from 'zod'; -import { getPlansData } from '../cli/list-plans.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { PlansList } from '../components/PlansList.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; +import { getPlansData } from '../../cli/list-plans.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { PlansList } from '../../components/PlansList.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; export const description = 'List all plans'; @@ -24,8 +23,7 @@ export default function ListPlansCommand({ options }: Props): React.ReactNode { if (jsonMode) { const timer = setTimeout(() => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); handleJsonOutput(() => { const result = getPlansData(config); if ('error' in result) { @@ -51,8 +49,7 @@ export default function ListPlansCommand({ options }: Props): React.ReactNode { return null; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); // Normal Ink rendering const result = getPlansData(config); diff --git a/packages/limps/src/commands/next-task.tsx b/packages/limps/src/commands/plan/next.tsx similarity index 81% rename from packages/limps/src/commands/next-task.tsx rename to packages/limps/src/commands/plan/next.tsx index 7d009ef..8e50d1d 100644 --- a/packages/limps/src/commands/next-task.tsx +++ b/packages/limps/src/commands/plan/next.tsx @@ -1,12 +1,11 @@ import { Text } from 'ink'; import { z } from 'zod'; import { useState, useEffect } from 'react'; -import { getNextTaskData, type TaskScoreBreakdown } from '../cli/next-task.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { NextTask } from '../components/NextTask.js'; -import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../cli/json-output.js'; +import { getNextTaskData, type TaskScoreBreakdown } from '../../cli/next-task.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { NextTask } from '../../components/NextTask.js'; +import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../../cli/json-output.js'; export const description = 'Get the next best task'; @@ -25,10 +24,10 @@ interface Props { export default function NextTaskCommand({ args, options }: Props): React.ReactNode { const [planId] = args; const help = buildHelpOutput({ - usage: 'limps next-task [options]', + usage: 'limps plan next [options]', arguments: ['plan Plan ID or name (e.g., "4" or "0004-feature-name")'], options: ['--config Path to config file', '--json Output as JSON'], - examples: ['limps next-task 4', 'limps next-task 0004-my-feature', 'limps next-task 4 --json'], + examples: ['limps plan next 4', 'limps plan next 0004-my-feature', 'limps plan next 4 --json'], sections: [ { title: 'Scoring Algorithm', @@ -55,8 +54,7 @@ export default function NextTaskCommand({ args, options }: Props): React.ReactNo 1 ); } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const data = await getNextTaskData(config, planId, { suppressWarnings: true }); if ('error' in data) { outputJson(wrapError(data.error, { code: 'NEXT_TASK_ERROR' }), 1); @@ -91,8 +89,7 @@ export default function NextTaskCommand({ args, options }: Props): React.ReactNo useEffect(() => { const run = async (): Promise => { try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const data = await getNextTaskData(config, planId); setResult(data); } catch (err) { diff --git a/packages/limps/src/commands/repair-plans.tsx b/packages/limps/src/commands/plan/repair.tsx similarity index 96% rename from packages/limps/src/commands/repair-plans.tsx rename to packages/limps/src/commands/plan/repair.tsx index d035ee4..3a07547 100644 --- a/packages/limps/src/commands/repair-plans.tsx +++ b/packages/limps/src/commands/plan/repair.tsx @@ -3,15 +3,15 @@ import { useEffect } from 'react'; import { z } from 'zod'; import { existsSync, readdirSync, readFileSync } from 'fs'; import { join } from 'path'; -import { type ServerConfig, loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; +import { type ServerConfig } from '../../config.js'; +import { loadCommandContext } from '../../core/command-context.js'; import { repairPlanFrontmatter, inspectPlanFrontmatter, inspectAgentFrontmatter, repairAgentFrontmatter, -} from '../cli/plan-repair.js'; -import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../cli/json-output.js'; +} from '../../cli/plan-repair.js'; +import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../../cli/json-output.js'; export const description = 'Repair malformed plan frontmatter (priority/severity)'; @@ -54,8 +54,7 @@ export default function RepairPlansCommand({ args, options }: Props): React.Reac const jsonMode = isJsonMode(options); const getConfig = (): ServerConfig => { - const configPath = resolveConfigPath(options.config); - return loadConfig(configPath); + return loadCommandContext(options.config).config; }; const getPlanDirs = (plansPath: string): string[] => @@ -227,7 +226,7 @@ export default function RepairPlansCommand({ args, options }: Props): React.Reac } } lines.push(''); - lines.push('Run `limps repair-plans` to apply fixes.'); + lines.push('Run `limps plan repair` to apply fixes.'); return {lines.join('\n')}; } diff --git a/packages/limps/src/commands/score-task.tsx b/packages/limps/src/commands/plan/score.tsx similarity index 54% rename from packages/limps/src/commands/score-task.tsx rename to packages/limps/src/commands/plan/score.tsx index 5b054f2..6f847e2 100644 --- a/packages/limps/src/commands/score-task.tsx +++ b/packages/limps/src/commands/plan/score.tsx @@ -1,19 +1,21 @@ import { Text } from 'ink'; import { useEffect } from 'react'; import { z } from 'zod'; -import { getScoredTaskById } from '../cli/next-task.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../cli/json-output.js'; +import { getScoredTaskById } from '../../cli/next-task.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { resolveTaskIdFromPlanAndAgent } from '../../core/task-target.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../../cli/json-output.js'; -export const description = 'Show scoring breakdown for a task'; +export const description = 'Show scoring breakdown for a specific agent task'; -export const args = z.tuple([z.string().describe('task id (e.g., 0004-feature#001)').optional()]); +export const args = z.tuple([]); export const options = z.object({ config: z.string().optional().describe('Path to config file'), json: z.boolean().optional().describe('Output as JSON'), + plan: z.string().optional().describe('Plan ID or name (e.g., "4" or "0004-feature-name")'), + agent: z.string().optional().describe('Agent number (e.g., "3" or "003")'), }); interface Props { @@ -22,12 +24,25 @@ interface Props { } export default function ScoreTaskCommand({ args, options }: Props): React.ReactNode { - const [taskId] = args; + void args; + const { plan: planId, agent: agentId } = options; const help = buildHelpOutput({ - usage: 'limps score-task [options]', - arguments: ['task-id Task ID (e.g., "0004-feature#001")'], - options: ['--config Path to config file', '--json Output as JSON'], - examples: ['limps score-task 0004-feature#001', 'limps score-task 0004-feature#001 --json'], + usage: 'limps plan score --plan --agent [options]', + options: [ + '--plan Plan ID or name (required)', + '--agent Agent number in the plan (required)', + '--config Path to config file', + '--json Output as JSON', + ], + examples: [ + 'limps plan score --plan 4 --agent 3', + 'limps plan score --plan 0004-feature-name --agent 003', + 'limps plan score --plan 4 --agent 3 --json', + ], + tips: [ + 'Use short IDs for convenience: --plan 4 --agent 3', + 'For all candidate tasks in a plan, run: limps plan scores --plan 4', + ], }); const jsonMode = isJsonMode(options); @@ -37,14 +52,18 @@ export default function ScoreTaskCommand({ args, options }: Props): React.ReactN } const timer = setTimeout(() => { try { - if (!taskId) { + if (!planId || !agentId) { outputJson( - wrapError('Task ID is required', { code: 'MISSING_TASK_ID', help: help.meta }), + wrapError('--plan and --agent are required', { + code: 'MISSING_PLAN_OR_AGENT', + help: help.meta, + }), 1 ); + return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); + const taskId = resolveTaskIdFromPlanAndAgent(config, planId, agentId); const result = getScoredTaskById(config, taskId, { suppressWarnings: true }); if ('error' in result) { outputJson(wrapError(result.error, { code: 'SCORE_TASK_ERROR' }), 1); @@ -60,19 +79,25 @@ export default function ScoreTaskCommand({ args, options }: Props): React.ReactN } }, 0); return () => clearTimeout(timer); - }, [help.meta, jsonMode, options.config, taskId]); + }, [agentId, help.meta, jsonMode, options.config, planId]); if (jsonMode) { return null; } - if (!taskId) { - return {help.text}; + if (!planId || !agentId) { + return ( + + Error: --plan and --agent are required + {'\n\n'} + {help.text} + + ); } try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); + const taskId = resolveTaskIdFromPlanAndAgent(config, planId, agentId); const result = getScoredTaskById(config, taskId); if ('error' in result) { return {result.error}; diff --git a/packages/limps/src/commands/score-all.tsx b/packages/limps/src/commands/plan/scores.tsx similarity index 67% rename from packages/limps/src/commands/score-all.tsx rename to packages/limps/src/commands/plan/scores.tsx index 01b36bf..b46836e 100644 --- a/packages/limps/src/commands/score-all.tsx +++ b/packages/limps/src/commands/plan/scores.tsx @@ -1,17 +1,17 @@ import { Text } from 'ink'; import { useEffect } from 'react'; import { z } from 'zod'; -import { getScoredTasksData } from '../cli/next-task.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../cli/json-output.js'; +import { getScoredTasksData } from '../../cli/next-task.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { isJsonMode, outputJson, wrapSuccess, wrapError } from '../../cli/json-output.js'; export const description = 'Compare scores across all available tasks'; -export const args = z.tuple([z.string().describe('plan id or name').optional()]); +export const args = z.tuple([]); export const options = z.object({ + plan: z.string().optional().describe('Plan ID or name (e.g., "4" or "0004-feature-name")'), config: z.string().optional().describe('Path to config file'), json: z.boolean().optional().describe('Output as JSON'), }); @@ -22,16 +22,21 @@ interface Props { } export default function ScoreAllCommand({ args, options }: Props): React.ReactNode { - const [planId] = args; + void args; + const planId = options.plan; const help = buildHelpOutput({ - usage: 'limps score-all [options]', - arguments: ['plan Plan ID or name (e.g., "4" or "0004-feature-name")'], - options: ['--config Path to config file', '--json Output as JSON'], + usage: 'limps plan scores --plan [options]', + options: [ + '--plan Plan ID or name (required)', + '--config Path to config file', + '--json Output as JSON', + ], examples: [ - 'limps score-all 4', - 'limps score-all 0004-feature-name', - 'limps score-all 4 --json', + 'limps plan scores --plan 4', + 'limps plan scores --plan 0004-feature-name', + 'limps plan scores --plan 4 --json', ], + tips: ['Use limps plan score --plan --agent for one task.'], }); const jsonMode = isJsonMode(options); @@ -43,12 +48,12 @@ export default function ScoreAllCommand({ args, options }: Props): React.ReactNo try { if (!planId) { outputJson( - wrapError('Plan ID is required', { code: 'MISSING_PLAN_ID', help: help.meta }), + wrapError('--plan is required', { code: 'MISSING_PLAN_ID', help: help.meta }), 1 ); + return; } - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = getScoredTasksData(config, planId, { suppressWarnings: true }); if ('error' in result) { outputJson(wrapError(result.error, { code: 'SCORE_ALL_ERROR' }), 1); @@ -71,12 +76,17 @@ export default function ScoreAllCommand({ args, options }: Props): React.ReactNo } if (!planId) { - return {help.text}; + return ( + + Error: --plan is required + {'\n\n'} + {help.text} + + ); } try { - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const result = getScoredTasksData(config, planId); if ('error' in result) { return {result.error}; diff --git a/packages/limps/src/commands/status.tsx b/packages/limps/src/commands/plan/status.tsx similarity index 84% rename from packages/limps/src/commands/status.tsx rename to packages/limps/src/commands/plan/status.tsx index 551de33..7c8f894 100644 --- a/packages/limps/src/commands/status.tsx +++ b/packages/limps/src/commands/plan/status.tsx @@ -1,21 +1,25 @@ import React from 'react'; import { Text } from 'ink'; import { z } from 'zod'; -import { getPlanStatusSummary, getAgentStatusSummary, updateAgentStatus } from '../cli/status.js'; -import { loadConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { buildHelpOutput } from '../utils/cli-help.js'; -import { PlanStatus } from '../components/PlanStatus.js'; -import { AgentStatus } from '../components/AgentStatus.js'; +import { + getPlanStatusSummary, + getAgentStatusSummary, + updateAgentStatus, +} from '../../cli/status.js'; +import type { ServerConfig } from '../../config.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { buildHelpOutput } from '../../utils/cli-help.js'; +import { PlanStatus } from '../../components/PlanStatus.js'; +import { AgentStatus } from '../../components/AgentStatus.js'; import { handleJsonOutput, isJsonMode, outputJson, wrapError, wrapSuccess, -} from '../cli/json-output.js'; +} from '../../cli/json-output.js'; import { useEffect } from 'react'; -import { resolveTaskId } from '../cli/task-resolver.js'; +import { resolveTaskId } from '../../cli/task-resolver.js'; export const description = 'Show plan or agent status'; @@ -43,7 +47,7 @@ interface Props { interface AgentStatusLoaderProps { agentId: string; planContext: string | undefined; - config: ReturnType; + config: ServerConfig; } function AgentStatusLoader({ @@ -85,10 +89,9 @@ function AgentStatusLoader({ export default function StatusCommand({ args, options }: Props): React.ReactNode { const [planId] = args; - const configPath = resolveConfigPath(options.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(options.config); const help = buildHelpOutput({ - usage: 'limps status [options]', + usage: 'limps plan status [options]', arguments: ['plan Plan ID or name (e.g., "4" or "0004-feature-name")'], options: [ '--config Path to config file', @@ -101,22 +104,26 @@ export default function StatusCommand({ args, options }: Props): React.ReactNode { title: 'Agent Status Examples', lines: [ - 'limps status --agent 0001#002', - 'limps status 0001 --agent 002', - 'limps status --agent 0001#002 --json', + 'limps plan status --agent 0001#002', + 'limps plan status 0001 --agent 002', + 'limps plan status --agent 0001#002 --json', ], }, { title: 'Update Agent Status Examples', lines: [ - 'limps status 0001 --agent 002 --set PASS', - 'limps status --agent 0001#002 --set WIP', - 'limps status 0001 --agent 002 --set PASS --notes "Completed in PR #116"', + 'limps plan status 0001 --agent 002 --set PASS', + 'limps plan status --agent 0001#002 --set WIP', + 'limps plan status 0001 --agent 002 --set PASS --notes "Completed in PR #116"', ], }, { title: 'Plan Status Examples', - lines: ['limps status 4', 'limps status 0004-my-feature', 'limps status 4 --json'], + lines: [ + 'limps plan status 4', + 'limps plan status 0004-my-feature', + 'limps plan status 4 --json', + ], }, ], }); diff --git a/packages/limps/src/commands/serve.tsx b/packages/limps/src/commands/server/bridge.tsx similarity index 90% rename from packages/limps/src/commands/serve.tsx rename to packages/limps/src/commands/server/bridge.tsx index 1909f70..29ce368 100644 --- a/packages/limps/src/commands/serve.tsx +++ b/packages/limps/src/commands/server/bridge.tsx @@ -7,15 +7,15 @@ import React, { useEffect, useState } from 'react'; import { Text } from 'ink'; import { z } from 'zod'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { createServer } from '../server.js'; +import { createServer } from '../../server.js'; import { initServerResources, shutdownServerResources, type ServerResources, -} from '../server-shared.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { ensureDaemonRunning } from '../utils/daemon-manager.js'; -import { shutdownExtensions, type LoadedExtension } from '../extensions/loader.js'; +} from '../../server-shared.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { ensureDaemonRunning } from '../../utils/daemon-manager.js'; +import { shutdownExtensions, type LoadedExtension } from '../../extensions/loader.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; export const description = 'Start stdio-to-HTTP bridge for Claude Desktop/Cursor'; @@ -79,7 +79,7 @@ export default function Serve({ options: opts }: Props): React.ReactNode { const run = async (): Promise => { try { - const configPath = resolveConfigPath(opts.config); + const { configPath } = loadCommandContext(opts.config); // Ensure daemon is running (starts if needed) setStatus('Checking daemon status...'); @@ -130,10 +130,10 @@ export default function Serve({ options: opts }: Props): React.ReactNode { if (errorMessage.includes('ECONNREFUSED')) { setError( `Cannot connect to daemon. The daemon may have failed to start.\n` + - `Try starting it manually: limps start --foreground` + `Try starting it manually: limps server start --foreground` ); } else if (errorMessage.includes('Failed to start daemon')) { - setError(errorMessage + '\n\nTry: limps start --foreground'); + setError(errorMessage + '\n\nTry: limps server start --foreground'); } else { setError(`Failed to start bridge: ${errorMessage}`); } diff --git a/packages/limps/src/commands/server/index.tsx b/packages/limps/src/commands/server/index.tsx new file mode 100644 index 0000000..7caf11c --- /dev/null +++ b/packages/limps/src/commands/server/index.tsx @@ -0,0 +1,36 @@ +import { Text } from 'ink'; +import { buildHelpOutput } from '../../utils/cli-help.js'; + +export const description = 'HTTP daemon lifecycle commands'; + +export default function ServerCommand(): React.ReactNode { + const help = buildHelpOutput({ + usage: 'limps server ', + sections: [ + { + title: 'Commands', + lines: [ + 'start Start the limps daemon', + 'stop Stop the limps daemon', + 'status Show daemon status', + 'bridge Run stdio-to-HTTP bridge for MCP clients', + ], + }, + { + title: 'Examples', + lines: [ + 'limps server start', + 'limps server status', + 'limps server stop', + 'limps server bridge', + ], + }, + { + title: 'Help', + lines: ['Run `limps server --help` for details.'], + }, + ], + }); + + return {help.text}; +} diff --git a/packages/limps/src/commands/start.tsx b/packages/limps/src/commands/server/start.tsx similarity index 89% rename from packages/limps/src/commands/start.tsx rename to packages/limps/src/commands/server/start.tsx index fbd9eff..8491468 100644 --- a/packages/limps/src/commands/start.tsx +++ b/packages/limps/src/commands/server/start.tsx @@ -5,16 +5,16 @@ import { spawn } from 'child_process'; import { closeSync, openSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { loadConfig, getHttpServerConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { getPidFilePath, getRunningDaemon } from '../pidfile.js'; -import { getDaemonLogPath, ensureSystemLogDir } from '../utils/daemon-log.js'; -import { startHttpServer, stopHttpServer } from '../server-http.js'; +import { getHttpServerConfig } from '../../config.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { getPidFilePath, getRunningDaemon } from '../../pidfile.js'; +import { getDaemonLogPath, ensureSystemLogDir } from '../../utils/daemon-log.js'; +import { startHttpServer, stopHttpServer } from '../../server-http.js'; import { checkDaemonHealth, isDaemonHealthy, type HttpClientOptions, -} from '../utils/http-client.js'; +} from '../../utils/http-client.js'; export const description = 'Start the limps HTTP server'; @@ -37,8 +37,7 @@ export default function StartCommand({ options: opts }: Props): React.ReactNode useEffect(() => { const run = async (): Promise => { try { - const configPath = resolveConfigPath(opts.config); - const config = loadConfig(configPath); + const { configPath, config } = loadCommandContext(opts.config); const httpConfig = getHttpServerConfig(config); const port = opts.port ?? httpConfig.port; const host = opts.host ?? httpConfig.host; @@ -48,7 +47,7 @@ export default function StartCommand({ options: opts }: Props): React.ReactNode const existing = getRunningDaemon(pidFilePath); if (existing) { setError( - `limps daemon already running (PID ${existing.pid} on ${existing.host}:${existing.port}). Run 'limps stop' first.` + `limps daemon already running (PID ${existing.pid} on ${existing.host}:${existing.port}). Run 'limps server stop' first.` ); return; } @@ -74,7 +73,7 @@ export default function StartCommand({ options: opts }: Props): React.ReactNode // Daemon mode — spawn detached child const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); - const entryPath = resolve(__dirname, '../server-http-entry.js'); + const entryPath = resolve(__dirname, '../../server-http-entry.js'); ensureSystemLogDir(); const logPath = getDaemonLogPath(port); const logFd = openSync(logPath, 'a'); @@ -141,7 +140,7 @@ export default function StartCommand({ options: opts }: Props): React.ReactNode ? 'Daemon may be slow to start. Check system resources and daemon logs.' : !healthResult.ok && healthResult.error.code === 'NETWORK_ERROR' ? 'Cannot connect to daemon. Check if port is blocked or already in use.' - : 'Try running: limps start --foreground'; + : 'Try running: limps server start --foreground'; setError( `${errorMsg}\n${suggestion}\nLog: ${logPath}\n\nRun with DEBUG=1 for more details.` @@ -149,7 +148,7 @@ export default function StartCommand({ options: opts }: Props): React.ReactNode } } else { setError( - `Daemon may have failed to start. Check log: ${logPath}\nOr try: limps start --foreground` + `Daemon may have failed to start. Check log: ${logPath}\nOr try: limps server start --foreground` ); } setDone(true); diff --git a/packages/limps/src/commands/server-status.tsx b/packages/limps/src/commands/server/status.tsx similarity index 95% rename from packages/limps/src/commands/server-status.tsx rename to packages/limps/src/commands/server/status.tsx index 456a3ca..d461683 100644 --- a/packages/limps/src/commands/server-status.tsx +++ b/packages/limps/src/commands/server/status.tsx @@ -1,17 +1,17 @@ import React, { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import { z } from 'zod'; -import { loadConfig, getHttpServerConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; +import { getHttpServerConfig } from '../../config.js'; +import { loadCommandContext } from '../../core/command-context.js'; import { getPidFilePath, getRunningDaemon, discoverRunningDaemons, type PidFileContents, -} from '../pidfile.js'; -import { getDaemonLogPath } from '../utils/daemon-log.js'; -import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../cli/json-output.js'; -import { checkDaemonHealth, type HttpClientOptions } from '../utils/http-client.js'; +} from '../../pidfile.js'; +import { getDaemonLogPath } from '../../utils/daemon-log.js'; +import { handleJsonOutput, isJsonMode, outputJson, wrapError } from '../../cli/json-output.js'; +import { checkDaemonHealth, type HttpClientOptions } from '../../utils/http-client.js'; export const description = 'Show limps HTTP server status'; @@ -86,8 +86,7 @@ async function getDaemonStatus(daemon: PidFileContents): Promise { * Resolve config and gather daemon + health status for a specific project. */ function getServerStatus(opts: z.infer): Promise { - const configPath = resolveConfigPath(opts.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(opts.config); const httpConfig = getHttpServerConfig(config); const pidFilePath = getPidFilePath(httpConfig.port); const daemon = getRunningDaemon(pidFilePath); @@ -132,7 +131,7 @@ async function getProjectAndGlobalStatus( */ function hasConfig(configOpt?: string): boolean { try { - resolveConfigPath(configOpt); + loadCommandContext(configOpt); return true; } catch { return false; diff --git a/packages/limps/src/commands/stop.tsx b/packages/limps/src/commands/server/stop.tsx similarity index 91% rename from packages/limps/src/commands/stop.tsx rename to packages/limps/src/commands/server/stop.tsx index e994dfd..540861e 100644 --- a/packages/limps/src/commands/stop.tsx +++ b/packages/limps/src/commands/server/stop.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react'; import { Text } from 'ink'; import { z } from 'zod'; -import { loadConfig, getHttpServerConfig } from '../config.js'; -import { resolveConfigPath } from '../utils/config-resolver.js'; -import { getPidFilePath, getRunningDaemon, removePidFile } from '../pidfile.js'; +import { getHttpServerConfig } from '../../config.js'; +import { loadCommandContext } from '../../core/command-context.js'; +import { getPidFilePath, getRunningDaemon, removePidFile } from '../../pidfile.js'; export const description = 'Stop the limps HTTP server'; @@ -24,8 +24,7 @@ export default function StopCommand({ options: opts }: Props): React.ReactNode { let timeoutId: ReturnType | null = null; try { - const configPath = resolveConfigPath(opts.config); - const config = loadConfig(configPath); + const { config } = loadCommandContext(opts.config); const httpConfig = getHttpServerConfig(config); const pidFilePath = getPidFilePath(httpConfig.port); const daemon = getRunningDaemon(pidFilePath); diff --git a/packages/limps/src/core/command-context.ts b/packages/limps/src/core/command-context.ts new file mode 100644 index 0000000..1453373 --- /dev/null +++ b/packages/limps/src/core/command-context.ts @@ -0,0 +1,13 @@ +import { loadConfig, type ServerConfig } from '../config.js'; +import { resolveConfigPath } from '../utils/config-resolver.js'; + +export interface CommandContext { + configPath: string; + config: ServerConfig; +} + +export function loadCommandContext(configOption?: string): CommandContext { + const configPath = resolveConfigPath(configOption); + const config = loadConfig(configPath); + return { configPath, config }; +} diff --git a/packages/limps/src/core/completion.ts b/packages/limps/src/core/completion.ts new file mode 100644 index 0000000..7bad602 --- /dev/null +++ b/packages/limps/src/core/completion.ts @@ -0,0 +1,325 @@ +import { existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { loadConfig } from '../config.js'; +import { findPlansByPrefix, listAllPlans } from '../cli/task-resolver.js'; +import { resolveConfigPath } from '../utils/config-resolver.js'; + +const ROOT_COMMANDS = [ + 'server', + 'plan', + 'docs', + 'config', + 'health', + 'graph', + 'proposals', + 'completion', + 'init', + 'reset', + 'version', +]; + +const GROUP_COMMANDS: Record = { + server: ['start', 'stop', 'status', 'bridge'], + plan: ['create', 'list', 'agents', 'status', 'next', 'score', 'scores', 'repair'], + docs: ['list', 'search', 'create', 'update', 'delete', 'process', 'reindex', 'tags'], + 'docs tags': ['list', 'add', 'remove'], + config: ['show', 'scoring', 'path', 'show-resolution', 'print', 'update', 'upgrade'], + health: ['check', 'staleness', 'inference', 'drift'], + graph: ['reindex', 'health', 'search', 'trace', 'entity', 'overlap', 'check', 'suggest', 'watch'], + proposals: ['list', 'apply', 'apply-safe'], +}; + +const COMMAND_OPTIONS: Record = { + 'plan score': ['--plan', '--agent', '--config', '--json', '--help'], + 'plan scores': ['--plan', '--config', '--json', '--help'], + 'plan status': ['--agent', '--set', '--notes', '--config', '--json', '--help'], + 'plan agents': ['--config', '--json', '--help'], + 'plan next': ['--config', '--json', '--help'], + 'docs process': [ + '--pattern', + '--code', + '--json', + '--timeout', + '--max-docs', + '--pretty', + '--help', + ], + 'docs list': ['--config', '--depth', '--pattern', '--include-hidden', '--json', '--help'], + 'docs search': ['--config', '--limit', '--frontmatter', '--case-sensitive', '--json', '--help'], + 'docs create': ['--config', '--template', '--content', '--json', '--help'], + 'docs update': [ + '--config', + '--mode', + '--content', + '--patch', + '--no-backup', + '--force', + '--json', + '--help', + ], + 'docs delete': ['--config', '--confirm', '--permanent', '--json', '--help'], + 'docs tags list': ['--config', '--json', '--help'], + 'docs tags add': ['--config', '--json', '--tags', '--help'], + 'docs tags remove': ['--config', '--json', '--tags', '--help'], + 'server start': ['--config', '--foreground', '--port', '--host', '--help'], + 'server stop': ['--config', '--help'], + 'server status': ['--config', '--json', '--help'], + 'server bridge': ['--config', '--help'], + completion: ['--help'], + '*': ['--help'], +}; + +const STATUS_VALUES = ['GAP', 'WIP', 'PASS', 'BLOCKED']; + +interface CompletionContext { + configPath?: string; +} + +function uniq(values: string[]): string[] { + return [...new Set(values)]; +} + +function byPrefix(values: string[], prefix: string): string[] { + if (!prefix) { + return uniq(values).sort(); + } + + return uniq(values) + .filter((value) => value.startsWith(prefix)) + .sort(); +} + +function readConfigSafe(configPath?: string): ReturnType | null { + try { + const resolved = resolveConfigPath(configPath); + return loadConfig(resolved); + } catch { + return null; + } +} + +function getPlanSuggestions(prefix: string, ctx: CompletionContext): string[] { + const config = readConfigSafe(ctx.configPath); + if (!config) { + return []; + } + + return byPrefix(listAllPlans(config.plansPath), prefix); +} + +function getAgentSuggestions( + planInput: string | undefined, + prefix: string, + ctx: CompletionContext +): string[] { + if (!planInput) { + return []; + } + + const config = readConfigSafe(ctx.configPath); + if (!config) { + return []; + } + + const matches = findPlansByPrefix(config.plansPath, planInput); + if (matches.length !== 1) { + return []; + } + + const agentsDir = join(config.plansPath, matches[0], 'agents'); + if (!existsSync(agentsDir)) { + return []; + } + + const agents = readdirSync(agentsDir) + .map((file) => file.match(/^(\d+)_.*\.agent\.md$/)?.[1]) + .filter((value): value is string => Boolean(value)); + + return byPrefix(agents, prefix); +} + +function parseOptionValue(tokens: string[], option: string): string | undefined { + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token === option) { + const value = tokens[i + 1]; + if (value && !value.startsWith('-')) { + return value; + } + } + + if (token.startsWith(`${option}=`)) { + return token.slice(option.length + 1); + } + } + + return undefined; +} + +function positionalTokens(tokens: string[]): string[] { + const values: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.startsWith('--')) { + if (!token.includes('=')) { + const maybeValue = tokens[i + 1]; + if (maybeValue && !maybeValue.startsWith('-')) { + i += 1; + } + } + continue; + } + + if (token.startsWith('-')) { + continue; + } + + values.push(token); + } + + return values; +} + +function commandPath(positionals: string[]): string { + const first = positionals[0]; + const second = positionals[1]; + const third = positionals[2]; + + if (!first) { + return ''; + } + + if (first === 'docs' && second === 'tags' && third) { + return 'docs tags'; + } + + if (first === 'docs' && second === 'tags') { + return 'docs tags'; + } + + if (GROUP_COMMANDS[first] && second) { + return `${first} ${second}`; + } + + return first; +} + +function suggestByPosition( + positionals: string[], + current: string, + ctx: CompletionContext +): string[] { + const first = positionals[0]; + const second = positionals[1]; + + if (!first) { + return byPrefix(ROOT_COMMANDS, current); + } + + if (first === 'docs' && second === 'tags' && positionals.length <= 2) { + return byPrefix(GROUP_COMMANDS['docs tags'] ?? [], current); + } + + if (GROUP_COMMANDS[first] && positionals.length <= 1) { + return byPrefix(GROUP_COMMANDS[first] ?? [], current); + } + + if ( + first === 'plan' && + (second === 'agents' || second === 'status' || second === 'next') && + positionals.length <= 3 + ) { + return getPlanSuggestions(current, ctx); + } + + return []; +} + +export function getCompletionSuggestions(tokens: string[], ctx: CompletionContext = {}): string[] { + const raw = [...tokens]; + const current = raw.length > 0 ? raw[raw.length - 1] : ''; + const prior = raw.length > 0 ? raw.slice(0, -1) : []; + const priorPositionals = positionalTokens(prior); + const path = commandPath(priorPositionals); + + const prevToken = prior.length > 0 ? prior[prior.length - 1] : ''; + if (prevToken === '--set') { + return byPrefix(STATUS_VALUES, current); + } + + if (prevToken === '--plan') { + return getPlanSuggestions(current, ctx); + } + + if (prevToken === '--agent') { + const planFromOption = parseOptionValue(prior, '--plan'); + return getAgentSuggestions(planFromOption, current, ctx); + } + + if (current.startsWith('-')) { + const specific = COMMAND_OPTIONS[path] ?? []; + const generic = COMMAND_OPTIONS['*'] ?? []; + return byPrefix([...specific, ...generic], current); + } + + const byPosition = suggestByPosition(priorPositionals, current, ctx); + if (byPosition.length > 0) { + return byPosition; + } + + const fallbackOptions = COMMAND_OPTIONS[path] ?? COMMAND_OPTIONS['*'] ?? []; + return byPrefix(fallbackOptions, current); +} + +function zshCompletionScript(): string { + return [ + '#compdef limps', + '', + '_limps() {', + ' local -a results', + ' results=("${(@f)$(limps __complete -- ${words[@]:2})}")', + " _describe 'limps' results", + '}', + '', + 'compdef _limps limps', + '', + ].join('\n'); +} + +function bashCompletionScript(): string { + return [ + '_limps_completion() {', + " local IFS=$'\\\\n'", + ' COMPREPLY=( $( limps __complete -- "${COMP_WORDS[@]:1}" ) )', + '}', + '', + 'complete -F _limps_completion limps', + '', + ].join('\n'); +} + +function fishCompletionScript(): string { + return [ + 'function __limps_complete', + ' set -l args (commandline -opc)', + ' limps __complete -- $args[2..-1]', + 'end', + '', + "complete -c limps -f -a '(__limps_complete)'", + '', + ].join('\n'); +} + +export function getCompletionScript(shell: 'zsh' | 'bash' | 'fish'): string { + switch (shell) { + case 'zsh': + return zshCompletionScript(); + case 'bash': + return bashCompletionScript(); + case 'fish': + return fishCompletionScript(); + default: + return zshCompletionScript(); + } +} diff --git a/packages/limps/src/core/task-target.ts b/packages/limps/src/core/task-target.ts new file mode 100644 index 0000000..bf01842 --- /dev/null +++ b/packages/limps/src/core/task-target.ts @@ -0,0 +1,15 @@ +import type { ServerConfig } from '../config.js'; +import { resolveTaskId } from '../cli/task-resolver.js'; + +export function resolveTaskIdFromPlanAndAgent( + config: ServerConfig, + planId: string, + agentId: string +): string { + const resolved = resolveTaskId(agentId, { + plansPath: config.plansPath, + planContext: planId, + }); + + return resolved.taskId; +} diff --git a/packages/limps/src/server-http-entry.ts b/packages/limps/src/server-http-entry.ts index bb163c6..3d8386c 100644 --- a/packages/limps/src/server-http-entry.ts +++ b/packages/limps/src/server-http-entry.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * HTTP daemon entry point. - * Spawned as a detached child process by `limps start`. + * Spawned as a detached child process by `limps server start`. * Reads config path from argv[2]. */ diff --git a/packages/limps/src/server-http.ts b/packages/limps/src/server-http.ts index 980c384..cfe69d6 100644 --- a/packages/limps/src/server-http.ts +++ b/packages/limps/src/server-http.ts @@ -94,7 +94,7 @@ export async function startHttpServer( if (existing) { throw new Error( `limps daemon already running (PID ${existing.pid} on ${existing.host}:${existing.port}). ` + - `Run 'limps stop' first.` + `Run 'limps server stop' first.` ); } @@ -291,13 +291,13 @@ export async function startHttpServer( `Command: ${processInfo.command}`, ``, `To stop the process: kill ${processInfo.pid}`, - `Or use a different port: limps start --port `, + `Or use a different port: limps server start --port `, ].join('\n'); reject(new Error(errorMsg)); } else { reject( new Error( - `Port ${httpConfig.port} is already in use. Use a different port: limps start --port ` + `Port ${httpConfig.port} is already in use. Use a different port: limps server start --port ` ) ); } diff --git a/packages/limps/src/utils/daemon-manager.ts b/packages/limps/src/utils/daemon-manager.ts index 983ff25..5e720d3 100644 --- a/packages/limps/src/utils/daemon-manager.ts +++ b/packages/limps/src/utils/daemon-manager.ts @@ -93,7 +93,7 @@ export async function ensureDaemonRunning( } throw new Error( - `Failed to start daemon within ${timeoutMs}ms. Try running 'limps start' manually.` + `Failed to start daemon within ${timeoutMs}ms. Try running 'limps server start' manually.` ); } diff --git a/packages/limps/tests/cli/commands.test.tsx b/packages/limps/tests/cli/commands.test.tsx index ec57e1d..bf53932 100644 --- a/packages/limps/tests/cli/commands.test.tsx +++ b/packages/limps/tests/cli/commands.test.tsx @@ -77,7 +77,7 @@ describe('Pastel CLI Commands', () => { const configPath = createConfig({ plansPath: plansDir }); - const { default: ListPlansCommand } = await import('../../src/commands/list-plans.js'); + const { default: ListPlansCommand } = await import('../../src/commands/plan/list.js'); const { lastFrame } = render(); @@ -91,7 +91,7 @@ describe('Pastel CLI Commands', () => { const configPath = createConfig({ plansPath: plansDir }); - const { default: ListPlansCommand } = await import('../../src/commands/list-plans.js'); + const { default: ListPlansCommand } = await import('../../src/commands/plan/list.js'); const { lastFrame } = render(); diff --git a/packages/limps/tests/cli/completion.test.ts b/packages/limps/tests/cli/completion.test.ts new file mode 100644 index 0000000..66f6c55 --- /dev/null +++ b/packages/limps/tests/cli/completion.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { getCompletionSuggestions, getCompletionScript } from '../../src/core/completion.js'; + +describe('completion engine', () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `test-completion-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + function createConfig(overrides: Record = {}): string { + const projectDir = join(testDir, 'project'); + mkdirSync(projectDir, { recursive: true }); + const configPath = join(projectDir, 'config.json'); + writeFileSync( + configPath, + JSON.stringify({ + plansPath: overrides.plansPath ?? join(testDir, 'plans'), + dataPath: overrides.dataPath ?? join(testDir, 'data'), + docsPaths: [testDir], + fileExtensions: ['.md'], + scoring: { + weights: { dependency: 40, priority: 30, workload: 30 }, + biases: {}, + }, + ...overrides, + }) + ); + return configPath; + } + + it('suggests grouped top-level commands', () => { + const suggestions = getCompletionSuggestions(['']); + expect(suggestions).toContain('plan'); + expect(suggestions).toContain('docs'); + expect(suggestions).toContain('server'); + }); + + it('suggests plan subcommands', () => { + const suggestions = getCompletionSuggestions(['plan', '']); + expect(suggestions).toContain('list'); + expect(suggestions).toContain('score'); + expect(suggestions).toContain('scores'); + }); + + it('suggests plans and agents for plan score options', () => { + const plansDir = join(testDir, 'plans'); + const planDir = join(plansDir, '0001-completion-demo'); + const agentsDir = join(planDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + + writeFileSync( + join(agentsDir, '000_agent_one.agent.md'), + `--- +status: GAP +persona: coder +dependencies: [] +blocks: [] +files: [] +--- + +# Agent 0 +`, + 'utf-8' + ); + + const configPath = createConfig({ plansPath: plansDir }); + + const planSuggestions = getCompletionSuggestions(['plan', 'score', '--plan', ''], { + configPath, + }); + expect(planSuggestions).toContain('0001-completion-demo'); + + const agentSuggestions = getCompletionSuggestions( + ['plan', 'score', '--plan', '1', '--agent', ''], + { configPath } + ); + expect(agentSuggestions).toContain('000'); + }); + + it('returns shell scripts for completion setup', () => { + const zsh = getCompletionScript('zsh'); + const bash = getCompletionScript('bash'); + const fish = getCompletionScript('fish'); + + expect(zsh).toContain('compdef _limps limps'); + expect(bash).toContain('complete -F _limps_completion limps'); + expect(fish).toContain("complete -c limps -f -a '(__limps_complete)'"); + }); +}); diff --git a/packages/limps/tests/cli/e2e.test.ts b/packages/limps/tests/cli/e2e.test.ts index f5233f1..341246e 100644 --- a/packages/limps/tests/cli/e2e.test.ts +++ b/packages/limps/tests/cli/e2e.test.ts @@ -158,14 +158,14 @@ This is a test feature plan. expect(result.exitCode).toBe(0); expect(result.stdout).toContain('limps'); - expect(result.stdout).toContain('HTTP Server:'); - expect(result.stdout).toContain('Plan Management:'); + expect(result.stdout).toContain('Recommended Groups:'); + expect(result.stdout).toContain('Common Commands:'); }); }); describe('plan commands', () => { it('should list plans without crashing', async () => { - const result = await runCli(['list-plans', '--config', configPath]); + const result = await runCli(['plan', 'list', '--config', configPath]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Test Feature'); @@ -194,14 +194,14 @@ This is a test feature plan. 'utf-8' ); - const result = await runCli(['list-plans', '--config', emptyConfig]); + const result = await runCli(['plan', 'list', '--config', emptyConfig]); expect(result.exitCode).toBe(0); expect(result.stdout.toLowerCase()).toMatch(/no plans|empty/i); }); it('should show status for a plan', async () => { - const result = await runCli(['status', '0001', '--config', configPath]); + const result = await runCli(['plan', 'status', '0001', '--config', configPath]); expect(result.exitCode).toBe(0); // Should show some status information @@ -209,7 +209,7 @@ This is a test feature plan. }); it('should handle status with invalid plan gracefully', async () => { - const result = await runCli(['status', '9999', '--config', configPath]); + const result = await runCli(['plan', 'status', '9999', '--config', configPath]); // Should handle error gracefully (may exit with non-zero or show error message) expect(result.stdout.length + result.stderr.length).toBeGreaterThan(0); @@ -235,7 +235,7 @@ This is a test feature plan. }); it('should handle invalid config path gracefully', async () => { - const result = await runCli(['list-plans', '--config', '/nonexistent/config.json']); + const result = await runCli(['plan', 'list', '--config', '/nonexistent/config.json']); // Should show error message, not crash expect(result.stdout.length + result.stderr.length).toBeGreaterThan(0); @@ -253,7 +253,7 @@ This is a test feature plan. const isolatedDir = join(tmpdir(), `limps-no-config-${Date.now()}`); mkdirSync(isolatedDir, { recursive: true }); - const result = await runCli(['list-plans'], { + const result = await runCli(['plan', 'list'], { cwd: isolatedDir, env: { HOME: isolatedDir, diff --git a/packages/limps/tests/cli/process.test.tsx b/packages/limps/tests/cli/process.test.tsx index 858701c..84b105b 100644 --- a/packages/limps/tests/cli/process.test.tsx +++ b/packages/limps/tests/cli/process.test.tsx @@ -62,7 +62,7 @@ describe('process command', () => { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { it('shows error for missing code option', async () => { const configPath = createConfig(); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { it('shows error for non-existent file', async () => { const configPath = createConfig(); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [docsDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { it('shows error when both path and pattern provided', async () => { const configPath = createConfig(); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { it('shows error when neither path nor pattern provided', async () => { const configPath = createConfig(); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); const { lastFrame } = render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); render( { const configPath = createConfig({ docsPaths: [testDir] }); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); render( { it('outputs JSON error when path is missing', async () => { const configPath = createConfig(); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); render( { it('outputs JSON error when code is missing', async () => { const configPath = createConfig(); - const { default: ProcessCommand } = await import('../../src/commands/process.js'); + const { default: ProcessCommand } = await import('../../src/commands/docs/process.js'); render( { + let testDir: string; + + beforeEach(() => { + testDir = join( + tmpdir(), + `test-score-task-cmd-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + function createConfig(overrides: Record = {}): string { + const projectDir = join(testDir, 'project'); + mkdirSync(projectDir, { recursive: true }); + const configPath = join(projectDir, 'config.json'); + writeFileSync( + configPath, + JSON.stringify({ + plansPath: overrides.plansPath ?? join(testDir, 'plans'), + dataPath: overrides.dataPath ?? join(testDir, 'data'), + docsPaths: [testDir], + fileExtensions: ['.md'], + scoring: { + weights: { dependency: 40, priority: 30, workload: 30 }, + biases: {}, + }, + ...overrides, + }) + ); + return configPath; + } + + it('shows helpful guidance when --plan/--agent are missing', async () => { + const configPath = createConfig(); + const { default: ScoreTaskCommand } = await import('../../src/commands/plan/score.js'); + + const { lastFrame } = render(); + const output = lastFrame() ?? ''; + + expect(output).toContain('Error: --plan and --agent are required'); + expect(output).toContain('Usage: limps plan score --plan --agent [options]'); + }); + + it('scores a task using --plan and --agent', async () => { + const plansDir = join(testDir, 'plans'); + const planDir = join(plansDir, '0001-cli-ux'); + const agentsDir = join(planDir, 'agents'); + mkdirSync(agentsDir, { recursive: true }); + + writeFileSync( + join(agentsDir, '000_agent_ready.agent.md'), + `--- +status: GAP +persona: coder +dependencies: [] +blocks: [] +files: [] +--- + +# Agent 0: Ready +`, + 'utf-8' + ); + + const configPath = createConfig({ plansPath: plansDir }); + const { default: ScoreTaskCommand } = await import('../../src/commands/plan/score.js'); + + const { lastFrame } = render( + + ); + + const output = lastFrame() ?? ''; + expect(output).toContain('Task Score (0001-cli-ux):'); + expect(output).toContain('Task ID: 0001-cli-ux#000'); + expect(output).toContain('Total Score:'); + }); +}); diff --git a/packages/limps/tests/cli/tags.test.ts b/packages/limps/tests/cli/tags.test.ts index e4e75d6..8b1eb94 100644 --- a/packages/limps/tests/cli/tags.test.ts +++ b/packages/limps/tests/cli/tags.test.ts @@ -16,7 +16,7 @@ const CONFIG_PATH = join(TEST_DIR, 'config.json'); // Path to limps CLI const LIMPS_CLI = join(process.cwd(), 'dist', 'cli.js'); -describe('tags CLI commands', () => { +describe('docs tags CLI commands', () => { beforeEach(() => { // Clean up test directory if (existsSync(TEST_DIR)) { @@ -50,7 +50,7 @@ describe('tags CLI commands', () => { } }); - describe('limps tags list', () => { + describe('limps docs tags list', () => { it('lists tags from frontmatter', () => { const filePath = join(TEST_REPO_ROOT, 'plans', 'test.md'); mkdirSync(dirname(filePath), { recursive: true }); @@ -68,7 +68,7 @@ tags: ); const result = execSync( - `node ${LIMPS_CLI} tags list plans/test.md --config ${CONFIG_PATH} --json`, + `node ${LIMPS_CLI} docs tags list plans/test.md --config ${CONFIG_PATH} --json`, { encoding: 'utf-8', } @@ -99,7 +99,7 @@ This has #inline-tag and #another-tag in the content. ); const result = execSync( - `node ${LIMPS_CLI} tags list plans/test.md --config ${CONFIG_PATH} --json`, + `node ${LIMPS_CLI} docs tags list plans/test.md --config ${CONFIG_PATH} --json`, { encoding: 'utf-8', } @@ -113,7 +113,7 @@ This has #inline-tag and #another-tag in the content. }); }); - describe('limps tags add', () => { + describe('limps docs tags add', () => { it('adds tags to document', () => { const filePath = join(TEST_REPO_ROOT, 'plans', 'test.md'); mkdirSync(dirname(filePath), { recursive: true }); @@ -130,7 +130,7 @@ tags: ); const result = execSync( - `node ${LIMPS_CLI} tags add plans/test.md --tags new-tag another-tag --config ${CONFIG_PATH} --json`, + `node ${LIMPS_CLI} docs tags add plans/test.md --tags new-tag another-tag --config ${CONFIG_PATH} --json`, { encoding: 'utf-8', } @@ -163,7 +163,7 @@ Has #inline-tag here. ); const result = execSync( - `node ${LIMPS_CLI} tags add plans/test.md --tags new-tag --config ${CONFIG_PATH} --json`, + `node ${LIMPS_CLI} docs tags add plans/test.md --tags new-tag --config ${CONFIG_PATH} --json`, { encoding: 'utf-8', } @@ -183,7 +183,7 @@ Has #inline-tag here. }); }); - describe('limps tags remove', () => { + describe('limps docs tags remove', () => { it('removes tags from document', () => { const filePath = join(TEST_REPO_ROOT, 'plans', 'test.md'); mkdirSync(dirname(filePath), { recursive: true }); @@ -202,7 +202,7 @@ tags: ); const result = execSync( - `node ${LIMPS_CLI} tags remove plans/test.md --tags remove also-remove --config ${CONFIG_PATH} --json`, + `node ${LIMPS_CLI} docs tags remove plans/test.md --tags remove also-remove --config ${CONFIG_PATH} --json`, { encoding: 'utf-8', } @@ -233,7 +233,7 @@ tags: ); const result = execSync( - `node ${LIMPS_CLI} tags remove plans/test.md --tags only-tag --config ${CONFIG_PATH} --json`, + `node ${LIMPS_CLI} docs tags remove plans/test.md --tags only-tag --config ${CONFIG_PATH} --json`, { encoding: 'utf-8', } From e1a73e43fe3ba73d192ac6a75c6b2de537cdfc66 Mon Sep 17 00:00:00 2001 From: Paul Breuler <4356863+paulbreuler@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:06:42 -0600 Subject: [PATCH 2/3] fix(cli): align grouped UX, completion, and exit semantics --- README.md | 3 +- packages/limps/package.json | 2 +- packages/limps/src/cli.tsx | 18 ++++++++++-- packages/limps/src/cli/init-project.ts | 2 +- packages/limps/src/commands/__complete.tsx | 22 -------------- packages/limps/src/commands/plan/score.tsx | 3 ++ packages/limps/src/commands/plan/scores.tsx | 3 ++ packages/limps/src/core/completion.ts | 20 ++++++++----- packages/limps/tests/cli/completion.test.ts | 21 ++++++++++++++ packages/limps/tests/cli/e2e.test.ts | 15 ++++++++++ packages/limps/tests/cli/init-project.test.ts | 29 +++++++++++++++++++ 11 files changed, 101 insertions(+), 37 deletions(-) delete mode 100644 packages/limps/src/commands/__complete.tsx create mode 100644 packages/limps/tests/cli/init-project.test.ts diff --git a/README.md b/README.md index 720b68e..43b3f3e 100644 --- a/README.md +++ b/README.md @@ -691,9 +691,8 @@ limps completion zsh # Generate Zsh tab-completion script ```bash limps health check # Aggregate all health signals limps health staleness [plan] # Find stale plans/agents -limps health drift [plan] # Detect file reference drift limps health inference [plan] # Suggest status updates -limps proposals list # List auto-fix proposals +limps proposals [plan] # List auto-fix proposals limps proposals apply # Apply a proposal limps proposals apply-safe # Apply all safe proposals ``` diff --git a/packages/limps/package.json b/packages/limps/package.json index fd2692a..f29a270 100644 --- a/packages/limps/package.json +++ b/packages/limps/package.json @@ -16,7 +16,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "build": "rm -rf dist && tsc", + "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc", "dev": "tsc --watch", "type-check": "tsc --noEmit", "lint": "eslint src/ tests/ --max-warnings 0", diff --git a/packages/limps/src/cli.tsx b/packages/limps/src/cli.tsx index 947b21a..c8a68b5 100644 --- a/packages/limps/src/cli.tsx +++ b/packages/limps/src/cli.tsx @@ -3,12 +3,24 @@ import Pastel from 'pastel'; import { getPackageVersion } from './utils/version.js'; import { startHttpServer, stopHttpServer } from './server-http.js'; import { logRedactedError } from './utils/safe-logging.js'; +import { getCompletionSuggestions } from './core/completion.js'; -// Check if running start command with --foreground flag — bypass Ink for clean stdio const args = process.argv.slice(2); +const isCompletionRequest = process.env.LIMPS_COMPLETE === '1'; + +if (isCompletionRequest) { + const separatorIndex = args.indexOf('--'); + const tokens = separatorIndex === -1 ? args : args.slice(separatorIndex + 1); + const suggestions = getCompletionSuggestions(tokens, { + configPath: process.env.MCP_PLANNING_CONFIG, + }); + process.stdout.write(suggestions.join('\n')); + process.exit(0); +} + +// Check if running server start command with --foreground flag — bypass Ink for clean stdio const isStartForeground = - args.includes('--foreground') && - ((args[0] === 'server' && args[1] === 'start') || args[0] === 'start'); + args.includes('--foreground') && args[0] === 'server' && args[1] === 'start'; const wantsHelp = args.includes('--help') || args.includes('-h') || (isStartForeground && args.includes('help')); diff --git a/packages/limps/src/cli/init-project.ts b/packages/limps/src/cli/init-project.ts index dd47553..31254ba 100644 --- a/packages/limps/src/cli/init-project.ts +++ b/packages/limps/src/cli/init-project.ts @@ -82,7 +82,7 @@ export function initProject(targetPath = '.'): string { // Start the HTTP daemon lines.push('Next steps:\n'); lines.push('1. Start the HTTP daemon:'); - lines.push(` ${localPlannerPath} start --config "${configPath}"`); + lines.push(` ${localPlannerPath} server start --config "${configPath}"`); lines.push(''); lines.push('2. Generate MCP client config:'); lines.push(` ${localPlannerPath} config print --client claude-code --config "${configPath}"`); diff --git a/packages/limps/src/commands/__complete.tsx b/packages/limps/src/commands/__complete.tsx deleted file mode 100644 index 8af58ae..0000000 --- a/packages/limps/src/commands/__complete.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Text } from 'ink'; -import { z } from 'zod'; -import { getCompletionSuggestions } from '../core/completion.js'; - -export const description = 'Internal completion endpoint'; - -export const args = z.array(z.string()); - -export const options = z.object({ - config: z.string().optional().describe('Path to config file'), -}); - -interface Props { - args: z.infer; - options: z.infer; -} - -export default function InternalCompleteCommand({ args, options }: Props): React.ReactNode { - const tokens = args[0] === '--' ? args.slice(1) : args; - const suggestions = getCompletionSuggestions(tokens, { configPath: options.config }); - return {suggestions.join('\n')}; -} diff --git a/packages/limps/src/commands/plan/score.tsx b/packages/limps/src/commands/plan/score.tsx index 6f847e2..19d9be2 100644 --- a/packages/limps/src/commands/plan/score.tsx +++ b/packages/limps/src/commands/plan/score.tsx @@ -86,6 +86,7 @@ export default function ScoreTaskCommand({ args, options }: Props): React.ReactN } if (!planId || !agentId) { + process.exitCode = 1; return ( Error: --plan and --agent are required @@ -100,6 +101,7 @@ export default function ScoreTaskCommand({ args, options }: Props): React.ReactN const taskId = resolveTaskIdFromPlanAndAgent(config, planId, agentId); const result = getScoredTaskById(config, taskId); if ('error' in result) { + process.exitCode = 1; return {result.error}; } @@ -126,6 +128,7 @@ export default function ScoreTaskCommand({ args, options }: Props): React.ReactN return {lines.join('\n')}; } catch (error) { + process.exitCode = 1; return Error: {(error as Error).message}; } } diff --git a/packages/limps/src/commands/plan/scores.tsx b/packages/limps/src/commands/plan/scores.tsx index b46836e..c0028e9 100644 --- a/packages/limps/src/commands/plan/scores.tsx +++ b/packages/limps/src/commands/plan/scores.tsx @@ -76,6 +76,7 @@ export default function ScoreAllCommand({ args, options }: Props): React.ReactNo } if (!planId) { + process.exitCode = 1; return ( Error: --plan is required @@ -89,6 +90,7 @@ export default function ScoreAllCommand({ args, options }: Props): React.ReactNo const { config } = loadCommandContext(options.config); const result = getScoredTasksData(config, planId); if ('error' in result) { + process.exitCode = 1; return {result.error}; } @@ -113,6 +115,7 @@ export default function ScoreAllCommand({ args, options }: Props): React.ReactNo return {lines.join('\n')}; } catch (error) { + process.exitCode = 1; return Error: {(error as Error).message}; } } diff --git a/packages/limps/src/core/completion.ts b/packages/limps/src/core/completion.ts index 7bad602..22120c5 100644 --- a/packages/limps/src/core/completion.ts +++ b/packages/limps/src/core/completion.ts @@ -24,9 +24,9 @@ const GROUP_COMMANDS: Record = { docs: ['list', 'search', 'create', 'update', 'delete', 'process', 'reindex', 'tags'], 'docs tags': ['list', 'add', 'remove'], config: ['show', 'scoring', 'path', 'show-resolution', 'print', 'update', 'upgrade'], - health: ['check', 'staleness', 'inference', 'drift'], + health: ['check', 'staleness', 'inference'], graph: ['reindex', 'health', 'search', 'trace', 'entity', 'overlap', 'check', 'suggest', 'watch'], - proposals: ['list', 'apply', 'apply-safe'], + proposals: ['apply', 'apply-safe'], }; const COMMAND_OPTIONS: Record = { @@ -36,6 +36,7 @@ const COMMAND_OPTIONS: Record = { 'plan agents': ['--config', '--json', '--help'], 'plan next': ['--config', '--json', '--help'], 'docs process': [ + '--config', '--pattern', '--code', '--json', @@ -242,6 +243,9 @@ export function getCompletionSuggestions(tokens: string[], ctx: CompletionContex const prior = raw.length > 0 ? raw.slice(0, -1) : []; const priorPositionals = positionalTokens(prior); const path = commandPath(priorPositionals); + const effectiveCtx: CompletionContext = { + configPath: parseOptionValue(raw, '--config') ?? ctx.configPath, + }; const prevToken = prior.length > 0 ? prior[prior.length - 1] : ''; if (prevToken === '--set') { @@ -249,12 +253,12 @@ export function getCompletionSuggestions(tokens: string[], ctx: CompletionContex } if (prevToken === '--plan') { - return getPlanSuggestions(current, ctx); + return getPlanSuggestions(current, effectiveCtx); } if (prevToken === '--agent') { const planFromOption = parseOptionValue(prior, '--plan'); - return getAgentSuggestions(planFromOption, current, ctx); + return getAgentSuggestions(planFromOption, current, effectiveCtx); } if (current.startsWith('-')) { @@ -263,7 +267,7 @@ export function getCompletionSuggestions(tokens: string[], ctx: CompletionContex return byPrefix([...specific, ...generic], current); } - const byPosition = suggestByPosition(priorPositionals, current, ctx); + const byPosition = suggestByPosition(priorPositionals, current, effectiveCtx); if (byPosition.length > 0) { return byPosition; } @@ -278,7 +282,7 @@ function zshCompletionScript(): string { '', '_limps() {', ' local -a results', - ' results=("${(@f)$(limps __complete -- ${words[@]:2})}")', + ' results=("${(@f)$(LIMPS_COMPLETE=1 limps -- ${words[@]:2})}")', " _describe 'limps' results", '}', '', @@ -291,7 +295,7 @@ function bashCompletionScript(): string { return [ '_limps_completion() {', " local IFS=$'\\\\n'", - ' COMPREPLY=( $( limps __complete -- "${COMP_WORDS[@]:1}" ) )', + ' COMPREPLY=( $( LIMPS_COMPLETE=1 limps -- "${COMP_WORDS[@]:1}" ) )', '}', '', 'complete -F _limps_completion limps', @@ -303,7 +307,7 @@ function fishCompletionScript(): string { return [ 'function __limps_complete', ' set -l args (commandline -opc)', - ' limps __complete -- $args[2..-1]', + ' env LIMPS_COMPLETE=1 limps -- $args[2..-1]', 'end', '', "complete -c limps -f -a '(__limps_complete)'", diff --git a/packages/limps/tests/cli/completion.test.ts b/packages/limps/tests/cli/completion.test.ts index 66f6c55..112c4ba 100644 --- a/packages/limps/tests/cli/completion.test.ts +++ b/packages/limps/tests/cli/completion.test.ts @@ -91,11 +91,32 @@ files: [] expect(agentSuggestions).toContain('000'); }); + it('uses --config from typed tokens for plan suggestions', () => { + const plansDir = join(testDir, 'plans'); + mkdirSync(join(plansDir, '0001-token-config', 'agents'), { recursive: true }); + const configPath = createConfig({ plansPath: plansDir }); + + const suggestions = getCompletionSuggestions(['plan', 'next', '--config', configPath, ''], {}); + + expect(suggestions).toContain('0001-token-config'); + }); + + it('does not suggest non-existent subgroup commands', () => { + const healthSuggestions = getCompletionSuggestions(['health', '']); + const proposalSuggestions = getCompletionSuggestions(['proposals', '']); + + expect(healthSuggestions).not.toContain('drift'); + expect(proposalSuggestions).not.toContain('list'); + }); + it('returns shell scripts for completion setup', () => { const zsh = getCompletionScript('zsh'); const bash = getCompletionScript('bash'); const fish = getCompletionScript('fish'); + expect(zsh).toContain('LIMPS_COMPLETE=1 limps --'); + expect(bash).toContain('LIMPS_COMPLETE=1 limps --'); + expect(fish).toContain('env LIMPS_COMPLETE=1 limps --'); expect(zsh).toContain('compdef _limps limps'); expect(bash).toContain('complete -F _limps_completion limps'); expect(fish).toContain("complete -c limps -f -a '(__limps_complete)'"); diff --git a/packages/limps/tests/cli/e2e.test.ts b/packages/limps/tests/cli/e2e.test.ts index 341246e..bb455d1 100644 --- a/packages/limps/tests/cli/e2e.test.ts +++ b/packages/limps/tests/cli/e2e.test.ts @@ -142,6 +142,7 @@ This is a test feature plan. expect(result.exitCode).toBe(0); expect(result.stdout).toContain('limps'); expect(result.stdout).toContain('Commands:'); + expect(result.stdout).not.toContain('__complete'); expect(result.stdout).toContain('version'); }); @@ -248,6 +249,20 @@ This is a test feature plan. expect(result.stdout.length + result.stderr.length).toBeGreaterThan(0); }); + it('should return non-zero when plan score is missing required flags', async () => { + const result = await runCli(['plan', 'score']); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('--plan and --agent are required'); + }); + + it('should return non-zero when plan scores is missing required flags', async () => { + const result = await runCli(['plan', 'scores']); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('--plan is required'); + }); + it('should show clear error when no config is found', async () => { // Use isolated directory that has no .limps/config.json in tree const isolatedDir = join(tmpdir(), `limps-no-config-${Date.now()}`); diff --git a/packages/limps/tests/cli/init-project.test.ts b/packages/limps/tests/cli/init-project.test.ts new file mode 100644 index 0000000..97c14b9 --- /dev/null +++ b/packages/limps/tests/cli/init-project.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { initProject } from '../../src/cli/init-project.js'; + +describe('init-project', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + } + tempDirs.length = 0; + }); + + it('prints canonical server start command in next steps', () => { + const projectDir = join(tmpdir(), `test-init-project-${Date.now()}`); + tempDirs.push(projectDir); + + const output = initProject(projectDir); + + expect(output).toContain('server start --config'); + expect(output).toContain('server status --config'); + expect(output).toContain('server stop --config'); + }); +}); From a2b40e823df00a43b6e64acaecb3785191dcf9c8 Mon Sep 17 00:00:00 2001 From: Paul Breuler <4356863+paulbreuler@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:25:47 -0600 Subject: [PATCH 3/3] fix(completion): address review feedback on token handling --- packages/limps/src/core/completion.ts | 7 +++++-- packages/limps/tests/cli/completion.test.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/limps/src/core/completion.ts b/packages/limps/src/core/completion.ts index 22120c5..b24fc70 100644 --- a/packages/limps/src/core/completion.ts +++ b/packages/limps/src/core/completion.ts @@ -192,6 +192,9 @@ function commandPath(positionals: string[]): string { } if (first === 'docs' && second === 'tags' && third) { + if ((GROUP_COMMANDS['docs tags'] ?? []).includes(third)) { + return `docs tags ${third}`; + } return 'docs tags'; } @@ -282,7 +285,7 @@ function zshCompletionScript(): string { '', '_limps() {', ' local -a results', - ' results=("${(@f)$(LIMPS_COMPLETE=1 limps -- ${words[@]:2})}")', + ' results=("${(@f)$(LIMPS_COMPLETE=1 limps -- "${words[@]:2}")}")', " _describe 'limps' results", '}', '', @@ -307,7 +310,7 @@ function fishCompletionScript(): string { return [ 'function __limps_complete', ' set -l args (commandline -opc)', - ' env LIMPS_COMPLETE=1 limps -- $args[2..-1]', + ' env LIMPS_COMPLETE=1 limps -- "$args[2..-1]"', 'end', '', "complete -c limps -f -a '(__limps_complete)'", diff --git a/packages/limps/tests/cli/completion.test.ts b/packages/limps/tests/cli/completion.test.ts index 112c4ba..2d65a64 100644 --- a/packages/limps/tests/cli/completion.test.ts +++ b/packages/limps/tests/cli/completion.test.ts @@ -115,8 +115,9 @@ files: [] const fish = getCompletionScript('fish'); expect(zsh).toContain('LIMPS_COMPLETE=1 limps --'); + expect(zsh).toContain('limps -- "${words[@]:2}"'); expect(bash).toContain('LIMPS_COMPLETE=1 limps --'); - expect(fish).toContain('env LIMPS_COMPLETE=1 limps --'); + expect(fish).toContain('env LIMPS_COMPLETE=1 limps -- "$args[2..-1]"'); expect(zsh).toContain('compdef _limps limps'); expect(bash).toContain('complete -F _limps_completion limps'); expect(fish).toContain("complete -c limps -f -a '(__limps_complete)'");