diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7f47a7..633eb9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,15 +7,9 @@ on: branches: [main] jobs: - test-go: - name: Test Go (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - defaults: - run: - working-directory: thop-go + test: + name: Test + runs-on: ubuntu-latest steps: - name: Checkout code @@ -25,7 +19,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.21' - cache-dependency-path: thop-go/go.sum + cache-dependency-path: go.sum - name: Download dependencies run: go mod download @@ -34,19 +28,15 @@ jobs: run: go test -v -race -coverprofile=coverage.out ./... - name: Upload coverage - if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: - files: thop-go/coverage.out + files: coverage.out flags: go fail_ci_if_error: false - integration-test-go: - name: Integration Test Go + integration-test: + name: Integration Test runs-on: ubuntu-latest - defaults: - run: - working-directory: thop-go services: sshd: @@ -68,7 +58,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.21' - cache-dependency-path: thop-go/go.sum + cache-dependency-path: go.sum - name: Wait for SSH to be ready run: | @@ -86,13 +76,10 @@ jobs: THOP_INTEGRATION_TESTS: "1" run: go test -v -tags=integration ./internal/session/... - build-go: - name: Build Go + build: + name: Build runs-on: ubuntu-latest - needs: test-go - defaults: - run: - working-directory: thop-go + needs: test strategy: matrix: @@ -107,7 +94,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.21' - cache-dependency-path: thop-go/go.sum + cache-dependency-path: go.sum - name: Build env: @@ -120,14 +107,11 @@ jobs: uses: actions/upload-artifact@v4 with: name: thop-${{ matrix.goos }}-${{ matrix.goarch }} - path: thop-go/thop-${{ matrix.goos }}-${{ matrix.goarch }} + path: thop-${{ matrix.goos }}-${{ matrix.goarch }} - lint-go: - name: Lint Go + lint: + name: Lint runs-on: ubuntu-latest - defaults: - run: - working-directory: thop-go steps: - name: Checkout code @@ -137,88 +121,10 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.21' - cache-dependency-path: thop-go/go.sum + cache-dependency-path: go.sum - name: golangci-lint uses: golangci/golangci-lint-action@v4 with: version: latest - working-directory: thop-go args: --timeout=5m - - test-rust: - name: Test Rust (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - defaults: - run: - working-directory: thop-rust - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - with: - workspaces: thop-rust - - - name: Run tests - run: cargo test --verbose - - - name: Run clippy - run: cargo clippy -- -D warnings - - - name: Check formatting - run: cargo fmt -- --check - - build-rust: - name: Build Rust - runs-on: ubuntu-latest - needs: test-rust - defaults: - run: - working-directory: thop-rust - - strategy: - matrix: - target: - - x86_64-unknown-linux-gnu - - x86_64-apple-darwin - - aarch64-apple-darwin - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Install cross-compilation tools - if: contains(matrix.target, 'apple') - run: | - # Cross-compilation to macOS from Linux requires special setup - # For now, we'll just check that the code compiles - echo "Cross-compilation to macOS is best done on macOS runners" - - - name: Build (Linux) - if: contains(matrix.target, 'linux') - run: cargo build --release --target ${{ matrix.target }} - - - name: Check (macOS targets) - if: contains(matrix.target, 'apple') - run: cargo check --target ${{ matrix.target }} - - - name: Upload artifact (Linux) - if: contains(matrix.target, 'linux') - uses: actions/upload-artifact@v4 - with: - name: thop-rust-${{ matrix.target }} - path: thop-rust/target/${{ matrix.target }}/release/thop diff --git a/.gitignore b/.gitignore index fecaf30..55fa869 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,21 @@ +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +coverage.out +coverage.html + +# Dependency directories +vendor/ + # IDE .idea/ .vscode/ @@ -9,19 +27,15 @@ .DS_Store Thumbs.db -# Editor backups -*.bak -*.orig - -# Temporary files -*.tmp -*.temp +# Debug +__debug_bin -# Debian package build outputs +# Debian build artifacts +debian/.debhelper/ +debian/debhelper-build-stamp +debian/files +debian/*.substvars +debian/thop/ *.deb *.buildinfo *.changes - -# Build outputs -thop-go/thop -thop-rust/target/ diff --git a/thop-go/.golangci.yml b/.golangci.yml similarity index 100% rename from thop-go/.golangci.yml rename to .golangci.yml diff --git a/AGENTS.md b/AGENTS.md index baa7b46..e591a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,33 +12,25 @@ This document provides instructions for AI agents and automated tools contributi - State sharing between instances via file **Architecture**: Shell wrapper (no daemon) -**Languages**: Evaluating Go and Rust in Phase 0 +**Language**: Go ## Repository Structure ``` thop/ -├── PRD.md # Product requirements (v0.2.0) -├── RESEARCH.md # Architecture research findings ├── TODO.md # Task list by phase ├── PROGRESS.md # Completion tracking ├── CLAUDE.md # Claude Code-specific guide ├── AGENTS.md # This file -├── thop-go/ # Go prototype (Phase 0) -│ ├── cmd/ -│ ├── internal/ -│ └── go.mod -└── thop-rust/ # Rust prototype (Phase 0) - ├── src/ - └── Cargo.toml +├── cmd/ # Main entry point +├── internal/ # Internal packages +└── go.mod ``` ## Key Documents | Document | Purpose | |----------|---------| -| `PRD.md` | Complete requirements (v0.2.0 - shell wrapper) | -| `RESEARCH.md` | Architecture decisions and language evaluation | | `TODO.md` | Actionable task list with phases | | `PROGRESS.md` | Implementation status tracking | @@ -72,10 +64,8 @@ thop/ ### Before Starting Work -1. Read `PRD.md` for requirements context -2. Check `TODO.md` for current phase tasks -3. Review `PROGRESS.md` for status -4. Identify which prototype (Go or Rust) to work on +1. Check `TODO.md` for current phase tasks +2. Review `PROGRESS.md` for status ### During Development @@ -92,19 +82,11 @@ thop/ ## Technical Stack -### Go Prototype - **Language**: Go 1.21+ - **SSH**: `golang.org/x/crypto/ssh` - **Config**: `github.com/pelletier/go-toml` - **State**: JSON file with file locking -### Rust Prototype -- **Language**: Rust 1.70+ -- **SSH**: `russh` crate -- **Config**: `toml` crate -- **Async**: `tokio` -- **CLI**: `clap` - ## Slash Commands | Command | Action | @@ -137,8 +119,7 @@ All errors must be: | Phase | Focus | |-------|-------| -| **Phase 0** | Build prototypes in Go and Rust, evaluate | -| **Phase 1** | Core MVP in chosen language | +| **Phase 1** | Core MVP | | **Phase 2** | Robustness (reconnection, timeouts) | | **Phase 3** | Polish (SSH config, completions) | | **Phase 4** | Advanced (PTY, async) | @@ -162,18 +143,11 @@ All errors must be: ## Code Quality Standards -### Go - `gofmt` for formatting - `golint` for style - No `panic()` in production paths - Error wrapping with context -### Rust -- `rustfmt` for formatting -- `clippy` for lints -- No `unwrap()` in production paths -- Proper error propagation with `?` - ## Security Requirements - Never store passwords @@ -200,7 +174,5 @@ All errors must be: ## Getting Help -- `PRD.md` Section 5: Functional Requirements -- `PRD.md` Section 7: Technical Architecture -- `PRD.md` Section 11: Error Handling -- `RESEARCH.md`: Architecture decisions +- `TODO.md`: Task list and requirements +- `README.md`: User documentation diff --git a/CLAUDE.md b/CLAUDE.md index b2f8681..c148ed7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,21 +33,15 @@ └─────────────────────────────────────────────────────────────────┘ ``` -## Implementation Languages +## Implementation Language -We are evaluating both **Go** and **Rust**. Prototypes in both languages will be built in Phase 0. +thop is implemented in **Go**. ### Go Stack - SSH: `golang.org/x/crypto/ssh` - Config: `github.com/pelletier/go-toml` - CLI: Standard library or `cobra` -### Rust Stack -- SSH: `russh` -- Config: `toml` -- CLI: `clap` -- Async: `tokio` - ## Key Components ### Interactive Mode @@ -105,7 +99,25 @@ user = "deploy" ## Project Structure -### Go (`thop-go/`) +``` +thop/ +├── cmd/thop/ +│ └── main.go +├── internal/ +│ ├── cli/ +│ │ ├── interactive.go +│ │ ├── proxy.go +│ │ └── commands.go +│ ├── session/ +│ │ ├── manager.go +│ │ ├── local.go +│ │ └── ssh.go +│ ├── config/ +│ │ └── config.go +│ └── state/ +│ └── state.go +├── go.mod +└── go.sum ``` thop-go/ ├── cmd/ @@ -153,16 +165,8 @@ thop-rust/ ## Development Phases -### Phase 0: Language Evaluation -Build minimal prototypes in both Go and Rust: -- Interactive mode with prompt -- Local shell execution -- Single SSH session -- Basic slash commands -- Proxy mode - ### Phase 1: Core MVP -Full implementation in chosen language: +Full implementation: - Complete interactive and proxy modes - Multiple sessions - State management @@ -209,7 +213,7 @@ Exit codes: ## Common Tasks ### Adding a Slash Command -1. Add to command parser in `commands.go`/`commands.rs` +1. Add to command parser in `commands.go` 2. Implement handler function 3. Update `/help` output 4. Add tests diff --git a/thop-go/Makefile b/Makefile similarity index 98% rename from thop-go/Makefile rename to Makefile index 6887b72..63c6870 100644 --- a/thop-go/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# thop-go Makefile +# thop Makefile # Binary name BINARY_NAME=thop @@ -146,7 +146,7 @@ check: fmt vet test # Show help .PHONY: help help: - @echo "thop-go Makefile" + @echo "thop Makefile" @echo "" @echo "Usage:" @echo " make [target]" diff --git a/PRD.md b/PRD.md deleted file mode 100644 index c36be8e..0000000 --- a/PRD.md +++ /dev/null @@ -1,919 +0,0 @@ -# Product Requirements Document: thop - -## Terminal Hopper for Agents - -> *Seamlessly switch between local and remote terminals without interrupting agent flow* - -**Version:** 0.2.0 -**Status:** Revised -**Last Updated:** January 16, 2026 -**Implementation Language:** Go and Rust (evaluating both) - ---- - -## 1. Executive Summary - -**thop** is a command-line tool that enables AI coding agents (such as Claude Code) to seamlessly execute commands on remote systems without the complexity of managing SSH connections. It maintains persistent SSH sessions in the background and allows instant, non-blocking switching between local and remote terminal contexts with a single command. - -### Core Principle: Never Block the Agent - -Traditional SSH workflows block on password prompts, host key confirmations, and connection timeouts. This breaks AI agent flow. thop is designed to **never block**—if authentication fails or requires user input, it returns immediately with actionable error information that the agent can handle programmatically. - -### Problem Statement - -AI coding agents like Claude Code are powerful but fundamentally local—they execute commands in the terminal where they're running. When developers need the agent to work on remote servers, they face friction: - -1. **Connection overhead**: Each SSH command requires connection setup -2. **Context loss**: SSH sessions don't persist state between commands -3. **Blocking operations**: SSH commands block the terminal until complete -4. **Manual intervention**: Developers must explicitly manage remote vs. local execution -5. **Session fragility**: Disconnections require manual reconnection and context restoration - -### Solution - -thop is an interactive shell wrapper with two operating modes: - -1. **Interactive Mode**: Run `thop` to get an interactive shell with a `(local) $` prompt. Use slash commands (`/connect`, `/switch`) to manage sessions. - -2. **Proxy Mode**: Run `thop --proxy` as the `SHELL` environment variable for AI agents. Commands route transparently to the active session. - -The agent executes commands normally, and thop routes them to the appropriate destination (local or remote) based on the current context. Sessions persist within the thop process, and state is shared via a lightweight state file. - ---- - -## 2. Goals and Non-Goals - -### Goals - -- **G1**: Allow AI agents to execute commands on remote systems with zero connection overhead after initial setup -- **G2**: Provide instant, non-blocking switching between local and multiple remote contexts -- **G3**: Maintain persistent SSH sessions that survive network interruptions -- **G4**: Be completely transparent to the AI agent—no special command syntax required -- **G5**: Support multiple concurrent remote sessions (e.g., prod, staging, dev) -- **G6**: Preserve shell state (cwd, environment variables) across command invocations -- **G7**: Work with any AI agent or automation tool that uses stdin/stdout - -### Non-Goals - -- **NG1**: Replacing SSH—thop uses SSH under the hood -- **NG2**: Providing a GUI or TUI interface -- **NG3**: Managing SSH keys or credentials (uses existing SSH config) -- **NG4**: File transfer (use scp/rsync separately) -- **NG5**: Being an MCP server (though could be wrapped as one later) -- **NG6**: Session sharing between users - ---- - -## 3. User Personas - -### Primary: AI-Assisted Developer - -- Uses Claude Code, Cursor, or similar AI coding tools daily -- Has remote development servers (cloud VMs, home lab, work servers) -- Wants AI agent to seamlessly work across local and remote environments -- Comfortable with command line but wants reduced friction - -### Secondary: DevOps Engineer - -- Manages multiple servers and environments -- Uses AI agents for automation and scripting -- Needs to switch between environments frequently -- Values auditability and session persistence - -### Tertiary: Automation Pipeline - -- CI/CD systems that leverage AI agents -- Headless operation without human intervention -- Requires reliable, scriptable interface - ---- - -## 4. User Stories - -### Core Workflow - -**US1**: As a developer, I want to start thop and have it maintain connections to my configured servers so that I can switch contexts instantly. - -**US2**: As a developer, I want to type `thop prod` and have all subsequent commands execute on my production server transparently. - -**US3**: As a developer, I want to type `thop local` to return to executing commands locally. - -**US4**: As a developer, I want my SSH sessions to persist even if my network briefly disconnects so that I don't lose my working state. - -**US5**: As a developer, I want the AI agent to be completely unaware that commands are running remotely—it should just see normal stdout/stderr. - -### Session Management - -**US6**: As a developer, I want to see all active sessions with `thop status` so I know what's connected. - -**US7**: As a developer, I want to define named sessions in a config file so I don't have to remember connection details. - -**US8**: As a developer, I want sessions to automatically reconnect if the connection drops. - -**US9**: As a developer, I want to explicitly close a session with `thop close prod` when I'm done. - -### Advanced Usage - -**US10**: As a developer, I want to run a one-off command on a remote server without switching context: `thop exec prod "docker ps"`. - -**US11**: As a developer, I want to see command history per session for debugging. - -**US12**: As a developer, I want environment variables set in a session to persist across commands. - -**US13**: As a developer, I want the current working directory to persist across commands within a session. - ---- - -## 5. Functional Requirements - -### 5.1 Session Management - -| ID | Requirement | Priority | -|----|-------------|----------| -| FR1.1 | Daemon process maintains persistent SSH connections | P0 | -| FR1.2 | Sessions defined in `~/.config/thop/config.toml` | P0 | -| FR1.3 | Sessions can also be created ad-hoc via CLI | P1 | -| FR1.4 | Automatic reconnection on connection failure (with backoff) | P0 | -| FR1.5 | Maximum reconnection attempts configurable (default: 5) | P1 | -| FR1.6 | Session timeout for idle connections configurable | P2 | -| FR1.7 | Support for SSH config file (~/.ssh/config) host aliases | P0 | -| FR1.8 | Support for SSH key authentication (~/.ssh/id_*, ~/.ssh/config IdentityFile) | P0 | -| FR1.9 | Support for SSH agent forwarding | P1 | -| FR1.10 | Support for jump hosts / bastion servers | P2 | - -### 5.2 Authentication (Non-Blocking) - -| ID | Requirement | Priority | -|----|-------------|----------| -| FR2.1 | Automatically discover and use SSH keys from ~/.ssh/ | P0 | -| FR2.2 | Respect IdentityFile settings in ~/.ssh/config | P0 | -| FR2.3 | Use running ssh-agent if available | P0 | -| FR2.4 | **NEVER prompt or block** for password input | P0 | -| FR2.5 | If password required, return structured error with `AUTH_PASSWORD_REQUIRED` | P0 | -| FR2.6 | `thop auth --password` reads password from stdin (single line, no echo) | P0 | -| FR2.7 | `thop auth --password-env VAR` reads password from environment variable | P0 | -| FR2.8 | `thop auth --password-file PATH` reads password from file (0600 perms required) | P1 | -| FR2.9 | Cached credentials expire after configurable timeout (default: 1 hour) | P1 | -| FR2.10 | `thop auth --clear` removes cached credentials | P1 | -| FR2.11 | If host key verification fails, return `HOST_KEY_VERIFICATION_FAILED` (never auto-accept) | P0 | -| FR2.12 | `thop trust ` adds host key to known_hosts after displaying fingerprint | P1 | - -### 5.3 Context Switching - -| ID | Requirement | Priority | -|----|-------------|----------| -| FR3.1 | `thop ` changes active context | P0 | -| FR3.2 | `thop local` returns to local shell | P0 | -| FR3.3 | Context switch is instant (<50ms) | P0 | -| FR3.4 | Context switch is non-blocking | P0 | -| FR3.5 | Current context persists across thop restarts | P1 | -| FR3.6 | Context indicator available via `thop current` | P0 | - -### 5.4 Command Execution - -| ID | Requirement | Priority | -|----|-------------|----------| -| FR4.1 | Commands executed via stdin are routed to active session | P0 | -| FR4.2 | stdout/stderr from session returned to caller | P0 | -| FR4.3 | Exit codes preserved and returned accurately | P0 | -| FR4.4 | Shell state (cwd, env vars) persists within session | P0 | -| FR4.5 | Support for interactive commands (with PTY) | P1 | -| FR4.6 | Command timeout configurable (default: 300s) | P1 | -| FR4.7 | Async command execution with status polling | P2 | -| FR4.8 | Command interruption (Ctrl+C forwarding) | P1 | - -### 5.5 CLI Interface - -| ID | Requirement | Priority | -|----|-------------|----------| -| FR5.1 | `thop start` - Start the daemon | P0 | -| FR5.2 | `thop stop` - Stop the daemon | P0 | -| FR5.3 | `thop status` - Show all sessions and their state | P0 | -| FR5.4 | `thop ` - Change active context | P0 | -| FR5.5 | `thop current` - Print current context name | P0 | -| FR5.6 | `thop exec ""` - One-off execution | P1 | -| FR5.7 | `thop connect ` - Establish connection to session | P1 | -| FR5.8 | `thop close ` - Close specific session | P1 | -| FR5.9 | `thop auth [OPTIONS]` - Provide credentials for session | P0 | -| FR5.10 | `thop trust ` - Trust and add host key to known_hosts | P1 | -| FR5.11 | `thop logs [session]` - View session logs | P2 | -| FR5.12 | `thop config` - Edit/view configuration | P2 | - -### 5.6 Proxy Mode (Primary Interface for AI Agents) - -| ID | Requirement | Priority | -|----|-------------|----------| -| FR6.1 | `thop proxy` - Enter proxy mode, reading commands from stdin | P0 | -| FR6.2 | Proxy mode passes commands to active session transparently | P0 | -| FR6.3 | Proxy mode outputs session stdout/stderr to own stdout/stderr | P0 | -| FR6.4 | Proxy mode handles special commands (starting with `#thop`) | P1 | -| FR6.5 | Proxy mode can be used as SHELL environment variable | P1 | - ---- - -## 6. Non-Functional Requirements - -### 6.1 Performance - -| ID | Requirement | Target | -|----|-------------|--------| -| NFR1.1 | Context switch latency | < 50ms | -| NFR1.2 | Command routing overhead | < 10ms | -| NFR1.3 | Memory usage (daemon, idle) | < 50MB | -| NFR1.4 | Memory per active session | < 10MB | -| NFR1.5 | CPU usage (idle) | < 1% | - -### 6.2 Reliability - -| ID | Requirement | Target | -|----|-------------|--------| -| NFR2.1 | Daemon uptime | 99.9% (crashes auto-restart) | -| NFR2.2 | Session recovery after network interruption | < 30s | -| NFR2.3 | No command loss during brief disconnections | Buffered for 60s | -| NFR2.4 | Graceful degradation if daemon unavailable | Fall back to local | - -### 6.3 Security - -| ID | Requirement | Priority | -|----|-------------|----------| -| NFR3.1 | No credential storage—uses SSH agent/keys | P0 | -| NFR3.2 | Unix socket for daemon communication (user-only perms) | P0 | -| NFR3.3 | No sensitive data in logs | P0 | -| NFR3.4 | Session isolation between users | P0 | - -### 6.4 Compatibility - -| ID | Requirement | Priority | -|----|-------------|----------| -| NFR4.1 | Linux (Ubuntu 20.04+, Debian 11+, Fedora 36+) | P0 | -| NFR4.2 | macOS (12.0+) | P0 | -| NFR4.3 | WSL2 (Windows 11) | P1 | -| NFR4.4 | Works with bash, zsh, fish shells | P0 | -| NFR4.5 | Works with Claude Code, Cursor, Aider | P0 | - ---- - -## 7. Technical Architecture - -### 7.1 High-Level Architecture - -thop is a single binary with two operating modes: Interactive and Proxy. - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ User's Machine │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ thop (single binary) │ │ -│ │ ┌─────────────────────┬─────────────────────────────┐ │ │ -│ │ │ Interactive Mode │ Proxy Mode │ │ │ -│ │ │ - (local) $ prompt │ - SHELL compatible │ │ │ -│ │ │ - Slash commands │ - Line-by-line I/O │ │ │ -│ │ │ - Human UX │ - For AI agents │ │ │ -│ │ └─────────────────────┴─────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌──────────┴──────────┐ │ │ -│ │ ▼ ▼ │ │ -│ │ ┌─────────────────────────────────────────────────┐ │ │ -│ │ │ Session Manager │ │ │ -│ │ │ - Manages SSH connections │ │ │ -│ │ │ - Tracks state (cwd, env) per session │ │ │ -│ │ │ - Handles reconnection │ │ │ -│ │ └─────────────────────────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌───────┼───────┬───────────┐ │ │ -│ │ ▼ ▼ ▼ ▼ │ │ -│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ -│ │ │local │ │ prod │ │ stg │ │ dev │ │ │ -│ │ │shell │ │(SSH) │ │(SSH) │ │(SSH) │ │ │ -│ │ └──────┘ └──┬───┘ └──┬───┘ └──┬───┘ │ │ -│ └──────────────┼────────┼────────┼─────────────────────┘ │ -│ │ │ │ │ -└─────────────────┼────────┼────────┼──────────────────────────┘ - │ SSH │ │ - ┌──────────┘ │ └──────────┐ - ▼ ▼ ▼ -┌────────────┐ ┌────────────┐ ┌────────────┐ -│ Production │ │ Staging │ │ Dev │ -│ Server │ │ Server │ │ Server │ -└────────────┘ └────────────┘ └────────────┘ -``` - -### 7.2 Component Design - -#### 7.2.1 Interactive Mode (`thop`) - -- Default mode when running `thop` with no arguments -- Displays prompt with current context: `(local) $`, `(prod) $` -- Parses slash commands for session management -- Full PTY support for interactive commands -- Signal handling (Ctrl+C forwarded to active session) - -#### 7.2.2 Proxy Mode (`thop --proxy`) - -- Designed for AI agent integration -- Set as `SHELL` environment variable: `SHELL="thop --proxy" claude` -- Line-buffered I/O (reads stdin, writes stdout/stderr) -- No prompt modification -- Transparent command passthrough to active session - -#### 7.2.3 Session Manager - -- Manages local shell and SSH sessions -- Tracks per-session state (cwd, environment variables) -- Handles SSH connection lifecycle -- Implements reconnection with exponential backoff - -#### 7.2.4 State Sharing - -- State file at `~/.local/share/thop/state.json` -- File locking for concurrent access -- Allows multiple thop instances to share active session - -### 7.3 Data Flow - -``` -Interactive Mode Flow: -────────────────────── - -1. User types command at (prod) $ prompt - │ - ├─► If starts with "/" → Handle as slash command - │ /connect, /switch, /status, etc. - │ - └─► Otherwise → Route to active session - │ - ▼ -2. Session Manager sends to local shell or SSH - │ - ▼ -3. Output displayed to user - - -Proxy Mode Flow (AI Agent): -─────────────────────────── - -1. AI Agent spawns SHELL (thop --proxy) - │ - ▼ -2. Agent writes command to stdin - │ - ▼ -3. thop reads line, routes to active session - │ - ├─► [local] Execute in local shell - │ - └─► [remote] Send over SSH channel - │ - ▼ -4. Output streams to stdout/stderr - │ - ▼ -5. AI Agent reads output -``` - -### 7.4 State Management - -``` -State File (~/.local/share/thop/state.json): -──────────────────────────────────────────── -{ - "active_session": "prod", - "sessions": { - "local": { - "type": "local", - "cwd": "/home/user/projects", - "env": {} - }, - "prod": { - "type": "ssh", - "host": "prod.example.com", - "user": "deploy", - "connected": true, - "cwd": "/var/www/app", - "env": { - "RAILS_ENV": "production" - } - } - }, - "updated_at": "2026-01-16T14:22:15Z" -} -``` - -### 7.5 Slash Commands - -| Command | Action | -|---------|--------| -| `/connect ` | Establish SSH connection to configured session | -| `/switch ` | Change active context | -| `/local` | Switch to local shell (shortcut for `/switch local`) | -| `/status` | Show all sessions and connection state | -| `/close ` | Close SSH connection | -| `/help` | Show available commands | - -### 7.6 Project Structure - -``` -thop/ -├── cmd/ -│ └── thop/ -│ └── main.go # Entry point -├── internal/ -│ ├── cli/ -│ │ ├── interactive.go # Interactive mode -│ │ ├── proxy.go # Proxy mode -│ │ └── commands.go # Slash command handlers -│ ├── session/ -│ │ ├── manager.go # Session lifecycle management -│ │ ├── local.go # Local shell session -│ │ └── ssh.go # SSH session (golang.org/x/crypto/ssh) -│ ├── config/ -│ │ └── config.go # TOML configuration parsing -│ └── state/ -│ └── state.go # Shared state file management -├── go.mod -├── go.sum -├── Makefile -└── configs/ - └── example.toml -``` - ---- - -## 8. Configuration - -### 8.1 Configuration File - -Location: `~/.config/thop/config.toml` - -```toml -# Global settings -[settings] -default_session = "local" -command_timeout = 300 -reconnect_attempts = 5 -reconnect_backoff_base = 2 -log_level = "info" -state_file = "~/.local/share/thop/state.json" - -# Local session (always available) -[sessions.local] -type = "local" -shell = "/bin/bash" - -# Remote session example -[sessions.prod] -type = "ssh" -host = "prod.example.com" # Can reference ~/.ssh/config alias -user = "deploy" -port = 22 -identity_file = "~/.ssh/id_ed25519" -startup_commands = [ - "cd /var/www/app", - "source .env" -] - -[sessions.staging] -type = "ssh" -host = "staging" # Uses ~/.ssh/config -startup_commands = [ - "cd /var/www/app" -] - -# Jump host example -[sessions.internal] -type = "ssh" -host = "internal-server" -jump_host = "bastion.example.com" -user = "admin" -``` - -### 8.2 Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `THOP_CONFIG` | Path to config file | `~/.config/thop/config.toml` | -| `THOP_STATE_FILE` | Path to state file | `~/.local/share/thop/state.json` | -| `THOP_LOG_LEVEL` | Logging verbosity | `info` | -| `THOP_DEFAULT_SESSION` | Initial active session | `local` | - ---- - -## 9. CLI Reference - -### 9.1 Command Line Usage - -``` -thop - Terminal Hopper for Agents - -USAGE: - thop [OPTIONS] # Start interactive mode - thop --proxy # Start proxy mode (for AI agents) - thop --status # Show status and exit - thop --version # Show version and exit - thop --help # Show help and exit - -OPTIONS: - --proxy Run in proxy mode (SHELL compatible) - --status Show all sessions and exit - --config Use alternate config file - --json Output in JSON format - -v, --verbose Increase logging verbosity - -q, --quiet Suppress non-error output - -h, --help Print help information - -V, --version Print version - -EXIT CODES: - 0 Success - 1 Error (details in JSON to stderr) - 2 Authentication required - 3 Host key verification required -``` - -### 9.2 Interactive Mode Slash Commands - -When running `thop` in interactive mode, use slash commands: - -``` -SLASH COMMANDS (in interactive mode): - /connect Establish SSH connection to session - /switch Change active context - /local Switch to local shell (alias for /switch local) - /status Show all sessions and connection state - /close Close SSH connection - /auth Provide credentials for session - /trust Trust host key, add to known_hosts - /help Show available commands - -``` - -### 9.3 Examples - -```bash -# Start thop in interactive mode -$ thop -(local) $ ls -la # Commands run locally -(local) $ /connect prod # Establish SSH to prod -Connecting to prod (prod.example.com)... connected -(local) $ /switch prod # Switch context to prod -(prod) $ pwd # Commands now run on prod -/var/www/app -(prod) $ docker ps # Run commands remotely -(prod) $ /local # Switch back to local -(local) $ /status # Show all sessions -Sessions: - local [active] /home/user/projects - prod [connected] /var/www/app - -# If password required: -(local) $ /auth prod # Will prompt for password - -# Trust a new host (shows fingerprint): -(local) $ /trust staging - -# Use as shell for AI agent -$ SHELL="thop --proxy" claude -``` - ---- - -## 10. Integration Guide - -### 10.1 Claude Code Integration - -**Recommended: SHELL Override** -```bash -# Terminal 1: Setup thop session -$ thop -(local) $ /connect prod -(local) $ /switch prod -(prod) $ # Leave running - -# Terminal 2: Start Claude with thop as SHELL -$ SHELL="thop --proxy" claude # Claude's commands go through thop -``` - -**Alternative: Wrapper Script** -```bash -#!/bin/bash -# ~/bin/claude-thop -export SHELL="$(which thop) --proxy" -exec claude "$@" -``` - -**Future: Claude Code MCP** -```json -{ - "mcpServers": { - "thop": { - "command": "thop", - "args": ["--mcp-server"] - } - } -} -``` - -### 10.2 Generic AI Agent Integration - -Any AI agent that executes shell commands can use thop by setting SHELL: - -```python -import subprocess -import os - -# Set thop as the shell for subprocesses -os.environ["SHELL"] = "thop --proxy" - -# Execute commands - they route to the active thop session -result = subprocess.run( - ["thop", "--proxy"], - input="ls -la\n", - capture_output=True, - text=True -) -print(result.stdout) -``` - -### 10.3 Workflow - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ 1. User starts thop in Terminal 1 │ -│ $ thop │ -│ (local) $ /connect prod │ -│ (local) $ /switch prod │ -│ (prod) $ │ -│ │ -│ 2. User starts AI agent with thop as SHELL in Terminal 2 │ -│ $ SHELL="thop --proxy" claude │ -│ │ -│ 3. AI agent commands flow through thop to prod server │ -│ Claude: "Run ls -la" │ -│ → thop --proxy receives "ls -la" │ -│ → Routes to active session (prod) │ -│ → SSH executes on prod.example.com │ -│ → Output returned to Claude │ -└──────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 11. Error Handling - -### 11.1 Error Categories - -| Category | Code | Example | Handling | -|----------|------|---------|----------| -| Connection | `CONNECTION_FAILED` | SSH connection refused | Retry with backoff, return error | -| Connection | `CONNECTION_TIMEOUT` | Host unreachable | Return error with timeout info | -| Auth | `AUTH_PASSWORD_REQUIRED` | Key auth failed, password needed | Return error with auth instructions | -| Auth | `AUTH_KEY_REJECTED` | SSH key not accepted | Return error, suggest key setup | -| Auth | `AUTH_FAILED` | Wrong password | Return error, allow retry | -| Auth | `HOST_KEY_VERIFICATION_FAILED` | Unknown host key | Return error with fingerprint, suggest `thop trust` | -| Auth | `HOST_KEY_CHANGED` | Host key mismatch (MITM?) | Return error, require manual intervention | -| Timeout | `COMMAND_TIMEOUT` | Command exceeded timeout | Kill command, return timeout error | -| Session | `SESSION_NOT_FOUND` | Unknown session name | Return error, list available sessions | -| Session | `SESSION_DISCONNECTED` | Session lost connection | Attempt reconnect or return error | -| State | `STATE_FILE_ERROR` | Cannot read/write state | Return error with path info | - -### 11.2 Error Response Format - -All errors are returned as JSON to stderr with exit code 1: - -```json -{ - "error": true, - "code": "AUTH_PASSWORD_REQUIRED", - "message": "SSH key authentication failed. Password required for prod.", - "session": "prod", - "host": "prod.example.com", - "retryable": false, - "action_required": "auth", - "suggestion": "Run: thop auth prod --password" -} -``` - -### 11.3 Authentication Error Flow - -When connecting to a session that requires a password: - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────────┐ -│ thop prod │────►│ Try SSH key │────►│ AUTH_PASSWORD_REQUIRED (exit 1) │ -└─────────────┘ │ auth first │ │ Returns JSON with instructions │ - └─────────────┘ └─────────────────────────────────┘ - │ - ┌────────────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ Agent reads error, extracts code, handles programmatically: │ -│ │ -│ # Option 1: Password from environment (set by user beforehand) │ -│ thop auth prod --password-env PROD_SSH_PASSWORD │ -│ │ -│ # Option 2: Password from secure file │ -│ thop auth prod --password-file ~/.secrets/prod.pass │ -│ │ -│ # Option 3: Prompt user and pipe (interactive fallback) │ -│ read -s -p "Password for prod: " pw && echo "$pw" | thop auth prod │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ thop prod (retry - now succeeds with cached credentials) │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 12. Logging and Observability - -### 12.1 Log Locations - -- Daemon log: `~/.local/share/thop/daemon.log` -- Session logs: `~/.local/share/thop/sessions/.log` - -### 12.2 Log Format - -``` -2026-01-16T14:22:15.123Z INFO [daemon] Session 'prod' connected -2026-01-16T14:22:16.456Z DEBUG [prod] Executing: ls -la -2026-01-16T14:22:16.789Z DEBUG [prod] Output: total 42... -2026-01-16T14:22:16.790Z INFO [prod] Command completed (exit: 0) -``` - -### 12.3 Metrics (Future) - -- Commands executed per session -- Average command latency -- Connection uptime per session -- Reconnection frequency - ---- - -## 13. Testing Strategy - -### 13.1 Unit Tests - -- Session state management -- Configuration parsing -- Command routing logic -- Reconnection backoff calculation - -### 13.2 Integration Tests - -- Local shell execution -- SSH connection establishment -- Context switching -- Command timeout handling -- Reconnection after network drop - -### 13.3 End-to-End Tests - -- Full workflow with mock AI agent -- Multi-session scenarios -- Long-running command handling -- Stress testing with rapid context switches - -### 13.4 Test Environments - -- Docker containers for remote SSH targets -- Network simulation for disconnect testing -- CI pipeline with SSH test infrastructure - ---- - -## 14. Implementation Phases - -### Phase 0: Language Evaluation - -Implement minimal prototype in both Go and Rust to evaluate: - -**Go Prototype (`thop-go/`)** -- [ ] Basic interactive mode with prompt -- [ ] Local shell command execution -- [ ] Single SSH session using `golang.org/x/crypto/ssh` -- [ ] Slash command parsing (`/connect`, `/switch`, `/status`) -- [ ] Proxy mode (`--proxy`) - -**Rust Prototype (`thop-rust/`)** -- [ ] Basic interactive mode with prompt -- [ ] Local shell command execution -- [ ] Single SSH session using `russh` crate -- [ ] Slash command parsing (`/connect`, `/switch`, `/status`) -- [ ] Proxy mode (`--proxy`) - -**Evaluation Criteria:** -- Code complexity and maintainability -- Binary size and startup time -- SSH library ergonomics -- PTY handling ease -- Developer experience - -### Phase 1: Core MVP - -After language selection: - -- [ ] Interactive mode with `(session) $` prompt -- [ ] Local shell session management -- [ ] Single SSH session support -- [ ] Slash commands: `/connect`, `/switch`, `/local`, `/status`, `/close` -- [ ] Proxy mode (`--proxy`) for AI agents -- [ ] State file for session sharing -- [ ] Configuration file parsing (TOML) -- [ ] Basic error handling with JSON output - -### Phase 2: Robustness - -- [ ] Multiple concurrent SSH sessions -- [ ] Automatic reconnection with exponential backoff -- [ ] Session state persistence (cwd, env vars) -- [ ] Command timeout handling -- [ ] File locking for concurrent state access -- [ ] Signal handling (Ctrl+C forwarding) - -### Phase 3: Polish - -- [ ] SSH config file integration (`~/.ssh/config`) -- [ ] SSH key and agent support -- [ ] Jump host / bastion support -- [ ] Startup commands per session -- [ ] Logging infrastructure -- [ ] `/auth` and `/trust` commands -- [ ] Shell completions (bash, zsh, fish) - -### Phase 4: Advanced Features - -- [ ] PTY support for interactive commands -- [ ] Async command execution -- [ ] Command history per session -- [ ] MCP server wrapper (future) -- [ ] Metrics and observability - ---- - -## 15. Open Questions - -1. **Q: Should thop manage its own SSH connections or delegate to system SSH?** - - **Decision**: Use native SSH library (Go: `golang.org/x/crypto/ssh`, Rust: `russh`) - - Provides more control over connection lifecycle and non-blocking behavior - -2. **Q: How to handle long-running commands that exceed timeout?** - - Option A: Kill and return error - - Option B: Transition to async mode automatically - - **Recommendation**: Option B with clear status reporting - -3. **Q: Should we support Windows (native, not WSL)?** - - **Recommendation**: Not in initial release, evaluate demand later - -4. **Q: How to handle session-specific environment without polluting global state?** - - **Decision**: Track env changes per session in state file, replay on reconnect - -5. **Q: Daemon vs shell wrapper architecture?** - - **Decision**: Shell wrapper approach (no daemon) - - Simpler architecture, single binary, state shared via file - -6. **Q: Go vs Rust for implementation?** - - **Decision**: Prototype both, evaluate based on criteria in Phase 0 - ---- - -## 16. Success Metrics - -| Metric | Target | Measurement | -|--------|--------|-------------| -| Context switch latency | < 50ms p99 | Benchmark tests | -| Adoption | 1000 GitHub stars in 6 months | GitHub analytics | -| Reliability | < 1 crash per 1000 hours | Error tracking | -| User satisfaction | > 4.5/5 rating | User surveys | -| AI agent compatibility | Works with top 5 agents | Manual testing | - ---- - -## 17. Appendix - -### A. Competitive Analysis - -| Tool | Pros | Cons | -|------|------|------| -| mcp-ssh-session | MCP integration, persistent sessions | Requires MCP setup | -| tmux + SSH | Battle-tested, widely available | Manual session management | -| Tailscale SSH | Zero config networking | Not a session manager | -| mosh | Connection resilience | Not transparent to apps | - -### B. References - -- [MCP SSH Session](https://github.com/devnullvoid/mcp-ssh-session) -- [Claude Code Documentation](https://docs.anthropic.com/claude-code) -- [SSH Protocol Specification](https://www.rfc-editor.org/rfc/rfc4253) -- [tmux Manual](https://man.openbsd.org/tmux) - -### C. Glossary - -| Term | Definition | -|------|------------| -| Session | A persistent shell context (local or remote) | -| Context | The currently active session for command execution | -| Daemon | Background process managing sessions | -| Proxy | Mode where thop acts as transparent shell | -| PTY | Pseudo-terminal for interactive shell support | diff --git a/PROGRESS.md b/PROGRESS.md index 81d3961..f976d48 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # thop Implementation Progress **Architecture**: Shell Wrapper (v0.2.0) -**Languages**: Go (primary), Rust (maintained) +**Language**: Go ## Overview @@ -21,127 +21,12 @@ ## Phase 0: Language Evaluation ✅ -### Go Prototype (`thop-go/`) - COMPLETE +### Go Prototype - COMPLETE **Binary Size**: 4.8MB (release), 7.2MB (debug) **Build Time**: Fast (~2s) **Tests**: 105 passing -#### Project Setup -| Task | Status | Notes | -|------|--------|-------| -| Initialize Go module | Complete | github.com/scottgl9/thop | -| Add dependencies | Complete | go-toml/v2, x/crypto/ssh | -| Create project structure | Complete | cmd/, internal/, configs/ | - -#### Interactive Mode -| Task | Status | Notes | -|------|--------|-------| -| Main loop with prompt | Complete | (session) $ prompt | -| Slash command parsing | Complete | /connect, /switch, /status, etc. | -| Output display | Complete | stdout/stderr handling | - -#### Local Shell -| Task | Status | Notes | -|------|--------|-------| -| Command execution | Complete | Via shell subprocess | -| Capture stdout/stderr | Complete | bytes.Buffer capture | -| Exit code handling | Complete | ExitError handling | - -#### SSH Session -| Task | Status | Notes | -|------|--------|-------| -| SSH connection | Complete | golang.org/x/crypto/ssh | -| Command execution | Complete | Per-command sessions | -| Key authentication | Complete | Agent + key files | -| Auth error handling | Complete | Structured errors | - -#### Slash Commands -| Task | Status | Notes | -|------|--------|-------| -| `/connect` | Complete | With connection feedback | -| `/switch` | Complete | Auto-connects SSH sessions | -| `/local` | Complete | Alias for /switch local | -| `/status` | Complete | JSON and text output | -| `/help` | Complete | Full command list | - -#### Proxy Mode -| Task | Status | Notes | -|------|--------|-------| -| `--proxy` flag | Complete | SHELL compatible | -| Stdin reading | Complete | Line-by-line | -| Session routing | Complete | To active session | -| Output handling | Complete | Passthrough | - -#### Configuration -| Task | Status | Notes | -|------|--------|-------| -| TOML parsing | Complete | go-toml/v2 | -| Session loading | Complete | Local + SSH sessions | - ---- - -### Rust Prototype (`thop-rust/`) - COMPLETE - -**Binary Size**: 1.4MB (release) -**Build Time**: Fast (~24s for release) -**Tests**: 32 passing - -#### Project Setup -| Task | Status | Notes | -|------|--------|-------| -| Initialize Cargo project | Complete | Cargo.toml | -| Add dependencies | Complete | clap, toml, serde, ssh2, chrono, regex | -| Create project structure | Complete | src/{cli,config,session,state,restriction}/ | - -#### Interactive Mode -| Task | Status | Notes | -|------|--------|-------| -| Main loop with prompt | Complete | (session) $ prompt | -| Slash command parsing | Complete | /connect, /switch, /status, etc. | -| Output display | Complete | stdout/stderr handling | - -#### Local Shell -| Task | Status | Notes | -|------|--------|-------| -| Command execution | Complete | Via shell subprocess | -| Capture stdout/stderr | Complete | String capture | -| Exit code handling | Complete | ExitStatus handling | - -#### SSH Session -| Task | Status | Notes | -|------|--------|-------| -| SSH connection | Complete | ssh2 crate | -| Command execution | Complete | Per-command channels | -| Key authentication | Complete | Agent + key files | -| Auth error handling | Complete | Structured errors | - -#### Slash Commands -| Task | Status | Notes | -|------|--------|-------| -| `/connect` | Complete | With connection feedback | -| `/switch` | Complete | Auto-connects SSH sessions | -| `/local` | Complete | Alias for /switch local | -| `/status` | Complete | JSON and text output | -| `/help` | Complete | Full command list | - -#### Proxy Mode -| Task | Status | Notes | -|------|--------|-------| -| `--proxy` flag | Complete | SHELL compatible | -| `--restricted` flag | Complete | Blocks dangerous commands | -| Stdin reading | Complete | Line-by-line | -| Session routing | Complete | To active session | -| Output handling | Complete | Passthrough | - -#### Configuration -| Task | Status | Notes | -|------|--------|-------| -| TOML parsing | Complete | toml crate | -| Session loading | Complete | Local + SSH sessions | - ---- - ### Evaluation ✅ | Task | Status | Notes | |------|--------|-------| @@ -217,7 +102,7 @@ | Category | Status | Notes | |----------|--------|-------| -| Unit Tests | Complete | Go: 105 tests, Rust: 32 tests | +| Unit Tests | Complete | 105 tests | | Integration Tests | Complete | Docker-based SSH tests | | E2E Tests | In Progress | Proxy mode testing needed | | Test Infrastructure | Complete | GitHub Actions CI | @@ -228,8 +113,6 @@ | Task | Status | Notes | |------|--------|-------| -| PRD.md | Complete | v0.2.0 - Shell wrapper architecture | -| RESEARCH.md | Complete | Architecture research and decisions | | TODO.md | Complete | Task list for all phases | | PROGRESS.md | Complete | This file | | CLAUDE.md | Complete | Development guide | @@ -243,8 +126,8 @@ ## Changelog -### 2026-01-19 (latest) -- Added `--restricted` mode to both Go and Rust implementations +### 2026-01-19 +- Added `--restricted` mode to Go implementation - Blocks dangerous commands for AI agent safety: - Privilege escalation (sudo, su, doas) - Destructive file operations (rm, rmdir, shred, dd) @@ -259,15 +142,14 @@ ### 2026-01-16 - Completed Go prototype with full test suite (105 tests) -- Completed Rust prototype with full test suite (32 tests) -- Both implementations working: +- Go implementation working: - Interactive mode with slash commands - Proxy mode for AI agent integration - Local shell sessions - SSH sessions with key authentication - State persistence - TOML configuration -- Binary sizes: Go 4.8MB, Rust 1.4MB +- Binary size: Go 4.8MB - Added macOS cross-platform compatibility - Set up GitHub Actions CI with Codecov integration diff --git a/README.md b/README.md index 4df880c..f07db17 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A lightweight CLI tool that enables AI agents to execute commands across local a | macOS | arm64 (Apple Silicon) | ✅ Fully Supported | | Windows | - | ❌ Not Supported | -Both the Go and Rust implementations are tested on Linux and macOS via CI. +The Go implementation is tested on Linux and macOS via CI. ## Features @@ -29,7 +29,6 @@ Both the Go and Rust implementations are tested on Linux and macOS via CI. ### From Source (Go) ```bash -cd thop-go go build -o thop ./cmd/thop sudo mv thop /usr/local/bin/ ``` @@ -435,21 +434,19 @@ State is preserved across thop restarts and uses file locking for safe concurren ### Building ```bash -cd thop-go go build ./cmd/thop ``` ### Testing ```bash -cd thop-go go test ./... ``` ### Project Structure ``` -thop-go/ +thop/ ├── cmd/thop/ # Main entry point ├── internal/ │ ├── cli/ # CLI handling (interactive, proxy, completions) diff --git a/RESEARCH.md b/RESEARCH.md deleted file mode 100644 index ed7a5f4..0000000 --- a/RESEARCH.md +++ /dev/null @@ -1,508 +0,0 @@ -# thop Architecture Research - -**Date**: January 16, 2026 -**Status**: Complete -**Decision**: Shell wrapper approach with Go implementation - ---- - -## Executive Summary - -This document captures research findings comparing two architectural approaches for thop: - -1. **Daemon Approach** (original PRD) - Background service with Unix socket IPC -2. **Shell Wrapper Approach** (revised) - Interactive shell wrapper with proxy mode - -**Conclusion**: The shell wrapper approach is simpler, feasible, and recommended. Go is the optimal implementation language. - ---- - -## 1. Architecture Comparison - -### Original: Daemon Approach - -``` -┌─────────────┐ ┌─────────────────────────────────────┐ -│ AI Agent │ │ thopd (daemon) │ -└──────┬──────┘ │ ┌─────────────────────────────────┐│ - │ │ │ Session Manager ││ - │ stdin/out │ └──────────────┬──────────────────┘│ - ▼ │ │ │ -┌──────────────┐ │ ┌──────┐ ┌──────┐ ┌──────┐ │ -│ thop proxy │◄───┼─┤local │ │ prod │ │ stg │ │ -└──────────────┘ │ │shell │ │(SSH) │ │(SSH) │ │ - ▲ └─────────────────────────────────────┘ - │ Unix Socket -``` - -**Characteristics:** -- Background daemon process (`thopd`) -- CLI communicates via Unix socket RPC -- Separate proxy mode for AI agents -- State stored in daemon memory - -### Revised: Shell Wrapper Approach - -``` -┌─────────────────────────────────────┐ -│ thop (single binary, two modes) │ -├─────────────────────────────────────┤ -│ Interactive Mode Proxy Mode │ -│ - Shows prompt - SHELL compat │ -│ - Slash commands - Line-by-line │ -│ - Human UX - For agents │ -└────────────┬────────────────────────┘ - │ - ▼ - ┌─────────────────────────┐ - │ Session State Manager │ - │ - SSH connections │ - │ - Current context │ - │ - CWD/env per session │ - └─────────────────────────┘ - │ - ┌───────┴───────┐ - ▼ ▼ - Local SSH SSH Sessions - Shell (persistent) -``` - -**Characteristics:** -- Single binary with two modes -- Interactive mode: `thop` with `(local) $` prompt -- Proxy mode: `thop --proxy` for AI agent SHELL -- Slash commands for session management -- State shared via file or lightweight socket - -### Comparison Matrix - -| Aspect | Daemon Approach | Shell Wrapper Approach | -|--------|-----------------|------------------------| -| Complexity | Higher (daemon + CLI + proxy) | Lower (single binary) | -| State Persistence | Daemon memory (robust) | File/socket (simpler) | -| Process Management | Requires daemon lifecycle | Self-contained | -| AI Integration | Proxy mode via socket | SHELL environment variable | -| Human UX | CLI commands only | Interactive prompt + slash commands | -| Distribution | Two binaries | Single binary | -| Failure Recovery | Daemon restart needed | Reconnect on next command | - ---- - -## 2. AI Agent Integration Challenge - -### The Core Problem - -When Claude Code (or similar AI agents) runs bash commands, it spawns its own subprocess: - -```python -# What Claude does internally: -subprocess.run(["bash", "-c", "ls -la"]) -``` - -This creates a NEW shell process that doesn't inherit the parent's thop session. - -### Solution: SHELL Override - -```bash -# For AI agent use: -SHELL="thop --proxy" claude -``` - -When `SHELL` is set to thop, the AI agent's commands route through thop: - -``` -Claude Code - │ - ▼ spawns SHELL -thop --proxy - │ - ▼ routes to active session -Local Shell / SSH Session -``` - -### Workflow Example - -```bash -# Terminal 1: Human operator -$ thop -(local) $ /connect prod # Establish SSH connection -(local) $ /switch prod # Switch context to prod -(prod) $ pwd # Commands go to prod server -/var/www/app - -# Terminal 2: AI agent (same machine) -$ SHELL="thop --proxy" claude -# Claude's commands now route through thop to prod -``` - ---- - -## 3. Shell Wrapper Implementation Details - -### How Python venv Works (Reference) - -Python venv modifies PS1 by sourcing a script into the current shell: -```bash -PS1="(venv_name) ${PS1-}" -``` - -This doesn't apply to thop because: -- We can't modify the AI agent's shell -- We need to intercept commands, not just change the prompt - -### How rlwrap Works (Better Model) - -rlwrap is a readline wrapper that: -1. Spawns target command in a PTY -2. Monitors PTY mode (raw vs cooked) -3. Intercepts input for readline editing -4. Passes through to child process - -**Relevant patterns for thop:** -- PTY handling for interactive commands -- Input interception for slash commands -- Transparent passthrough for normal commands - -### Two Operating Modes - -**Mode A: Interactive (for humans)** -``` -$ thop -(local) $ /connect prod # Slash command → internal handling -(local) $ /switch prod -(prod) $ ls -la # Regular command → route to session -``` - -- Full PTY support -- Prompt modification showing context -- Slash command parsing -- Signal handling (Ctrl+C, Ctrl+Z) - -**Mode B: Proxy (for AI agents)** -``` -$ thop --proxy -# Reads stdin line-by-line -# Routes to active session -# Outputs to stdout/stderr -``` - -- Line-buffered I/O (no PTY needed) -- No prompt modification -- Simpler, faster -- SHELL-compatible - -### State Sharing Between Modes - -Options evaluated: - -| Method | Pros | Cons | -|--------|------|------| -| State file | Simple, no daemon | Concurrency issues | -| Unix socket (light) | Robust, handles concurrency | Slightly more complex | -| Environment variables | No persistence needed | Can't share SSH connections | - -**Recommendation**: Lightweight Unix socket for state coordination, but SSH connections managed per-process with reconnection support. - ---- - -## 4. Language Evaluation - -### Candidates - -| Language | Consideration | -|----------|---------------| -| Rust | Maximum performance, excellent PTY/SSH crates | -| Go | Great balance, excellent concurrency, simpler | -| TypeScript | Rapid development, but runtime dependency | -| Python | Easy development, but slow and requires interpreter | - -### Detailed Comparison - -#### Rust - -**Pros:** -- Fastest execution (2x faster than Go, 60x faster than Python) -- Excellent PTY support (`portable_pty`, `pty-process`) -- Good SSH libraries (`russh`, `ssh2`) -- Single binary distribution -- Memory safety guarantees - -**Cons:** -- Steeper learning curve -- Longer development time -- Borrow checker complexity for async + state management - -**Relevant Crates:** -- `clap` - CLI argument parsing -- `portable_pty` - Cross-platform PTY -- `russh` - Pure Rust SSH client -- `tokio` - Async runtime -- `ratatui` - Terminal UI (if needed) - -#### Go - -**Pros:** -- Excellent concurrency (goroutines trivialize multi-session management) -- Native SSH support (`golang.org/x/crypto/ssh`) -- Single binary distribution -- Fast enough (context switch < 50ms easily achievable) -- Simpler than Rust, faster development -- Battle-tested CLI ecosystem (Cobra, Viper) - -**Cons:** -- Slightly larger binaries than Rust -- No generics until recently (less relevant now) -- GC pauses (negligible for CLI tools) - -**Relevant Packages:** -- `cobra` - CLI framework -- `golang.org/x/crypto/ssh` - SSH client -- `creack/pty` - PTY handling -- `bubbletea` - Terminal UI (if needed) - -#### TypeScript - -**Pros:** -- Rapid development -- Good ecosystem (Ink for terminal UI) -- What Claude Code itself uses - -**Cons:** -- Requires Node.js runtime -- PTY support requires native dependencies -- Larger distribution size -- Slower startup time - -#### Python - -**Pros:** -- Fastest development time -- Good libraries (`paramiko` for SSH, `pexpect` for PTY) -- Easy to prototype - -**Cons:** -- Requires Python interpreter -- Slowest performance -- Distribution complexity (venv, dependencies) -- PTY handling is awkward - -### Performance Requirements Check - -From PRD: -- Context switch latency: < 50ms -- Command routing overhead: < 10ms -- Memory usage (idle): < 50MB - -All candidates can meet these requirements. Performance is not the differentiator. - -### Decision Matrix - -| Factor | Weight | Rust | Go | TypeScript | Python | -|--------|--------|------|-----|------------|--------| -| Development speed | 25% | 2 | 4 | 5 | 5 | -| Performance | 15% | 5 | 4 | 3 | 2 | -| Distribution | 20% | 5 | 5 | 2 | 2 | -| SSH support | 20% | 4 | 5 | 3 | 4 | -| PTY support | 10% | 5 | 4 | 2 | 3 | -| Maintainability | 10% | 3 | 4 | 4 | 4 | -| **Weighted Score** | | **3.7** | **4.4** | **3.3** | **3.4** | - -### Recommendation: Go - -Go provides the best balance for thop: - -1. **Single binary** - Easy distribution, no runtime dependencies -2. **Native SSH** - `golang.org/x/crypto/ssh` is production-grade -3. **Goroutines** - Trivial concurrency for managing multiple sessions -4. **Fast development** - Ship faster than Rust -5. **Ecosystem** - Cobra for CLI, excellent standard library - ---- - -## 5. Implementation Strategy - -### Revised Architecture - -``` -thop/ -├── cmd/ -│ └── thop/ -│ └── main.go # Entry point -├── internal/ -│ ├── cli/ -│ │ ├── interactive.go # Interactive mode -│ │ └── proxy.go # Proxy mode (SHELL compat) -│ ├── session/ -│ │ ├── manager.go # Session state management -│ │ ├── local.go # Local shell session -│ │ └── ssh.go # SSH session -│ ├── config/ -│ │ └── config.go # TOML configuration -│ └── state/ -│ └── state.go # Shared state (file/socket) -├── go.mod -├── go.sum -└── configs/ - └── example.toml -``` - -### Slash Commands - -| Command | Action | -|---------|--------| -| `/connect ` | Establish SSH connection | -| `/switch ` | Change active context | -| `/local` | Switch to local shell | -| `/status` | Show all sessions | -| `/close ` | Close SSH connection | -| `/help` | Show available commands | - -### State Management - -```go -type State struct { - ActiveSession string `json:"active_session"` - Sessions map[string]Session `json:"sessions"` -} - -type Session struct { - Name string `json:"name"` - Type string `json:"type"` // "local" or "ssh" - Connected bool `json:"connected"` - CWD string `json:"cwd"` - Env map[string]string `json:"env"` -} -``` - -### AI Agent Integration - -```bash -# Option 1: SHELL override (recommended) -SHELL="thop --proxy" claude - -# Option 2: Wrapper script -#!/bin/bash -# ~/bin/claude-thop -export SHELL="$(which thop) --proxy" -exec claude "$@" - -# Option 3: Shell alias -alias claude-remote='SHELL="thop --proxy" claude' -``` - ---- - -## 6. Key Implementation Challenges - -### Challenge 1: SSH Connection Persistence - -**Problem**: Each thop process needs access to SSH connections. - -**Solution Options**: -1. **Per-process connections**: Each thop instance maintains its own SSH. Reconnect as needed. -2. **Connection sharing via socket**: Light daemon holds connections, thop instances communicate. -3. **SSH ControlMaster**: Leverage OpenSSH's built-in connection sharing. - -**Recommendation**: Start with option 1 (simpler), add SSH ControlMaster support for optimization. - -### Challenge 2: State Synchronization - -**Problem**: Interactive mode and proxy mode need consistent view of active session. - -**Solution**: -- State file at `~/.local/share/thop/state.json` -- File locking for concurrent access -- Watch for changes (fsnotify) in interactive mode - -### Challenge 3: PTY Handling - -**Problem**: Interactive commands (vim, top) need PTY support. - -**Solution**: -- Interactive mode: Full PTY via `creack/pty` -- Proxy mode: Line-buffered I/O (PTY optional) -- Detect if stdin is TTY to choose mode - -### Challenge 4: Signal Forwarding - -**Problem**: Ctrl+C should interrupt remote command, not kill thop. - -**Solution**: -```go -// Catch SIGINT, forward to active session -signal.Notify(sigChan, syscall.SIGINT) -go func() { - for range sigChan { - activeSession.SendSignal(ssh.SIGINT) - } -}() -``` - ---- - -## 7. Migration from Original PRD - -### What Changes - -| Original PRD | New Approach | -|--------------|--------------| -| `thopd` daemon | Removed (no daemon) | -| `thop start/stop` | Removed (no daemon lifecycle) | -| `thop proxy` | `thop --proxy` flag | -| Unix socket IPC | State file + optional socket | -| Rust implementation | Go implementation | - -### What Stays the Same - -- Configuration file format (`~/.config/thop/config.toml`) -- Session concepts (local, SSH) -- Non-blocking authentication errors -- SSH config integration -- Environment variable overrides -- Error response format (JSON) - -### New Additions - -- Interactive mode with prompt `(session) $` -- Slash commands (`/connect`, `/switch`, etc.) -- `SHELL` compatibility for AI agents - ---- - -## 8. Conclusion - -### Recommendation Summary - -1. **Architecture**: Shell wrapper approach (no daemon) -2. **Language**: Go -3. **Modes**: Interactive (human) + Proxy (AI agent) -4. **State**: File-based with file locking -5. **SSH**: Per-process with ControlMaster optimization later - -### Benefits - -- Simpler architecture (single binary) -- Better human UX (interactive prompt) -- Same AI agent support (SHELL override) -- Faster development (Go vs Rust) -- Easier maintenance - -### Trade-offs Accepted - -- SSH connections not shared between processes (acceptable, use ControlMaster) -- File-based state vs in-memory (acceptable for CLI tool) -- Go vs Rust performance (negligible difference for this use case) - ---- - -## References - -- [Go vs Python vs Rust Comparison](https://pullflow.com/blog/go-vs-python-vs-rust-complete-performance-comparison) -- [Best Languages for CLI Utilities](https://www.slant.co/topics/2469/~best-languages-for-writing-command-line-utilities) -- [Rust vs Go in 2025](https://blog.jetbrains.com/rust/2025/06/12/rust-vs-go/) -- [AI CLI Tools Comparison](https://mer.vin/2025/12/ai-cli-tools-comparison-why-openai-switched-to-rust-while-claude-code-stays-with-typescript/) -- [rlwrap - readline wrapper](https://github.com/hanslub42/rlwrap) -- [portable_pty Rust crate](https://docs.rs/portable-pty) -- [russh - Rust SSH library](https://github.com/Eugeny/russh) -- [Go crypto/ssh package](https://pkg.go.dev/golang.org/x/crypto/ssh) -- [creack/pty - Go PTY package](https://github.com/creack/pty) diff --git a/TODO.md b/TODO.md index 018a9e2..eda5968 100644 --- a/TODO.md +++ b/TODO.md @@ -1,39 +1,18 @@ # thop TODO List -Tasks derived from PRD.md (v0.2.0 - Shell Wrapper Architecture). See PROGRESS.md for completion tracking. --- ## Phase 0: Language Evaluation ✅ COMPLETE -Both Go and Rust prototypes implemented and tested. - -### Go Prototype (`thop-go/`) ✅ -- [x] Project setup with go.mod -- [x] Interactive mode with prompt -- [x] Local shell execution -- [x] SSH session support -- [x] Slash commands (/connect, /switch, /local, /status, /help) -- [x] Proxy mode (--proxy) -- [x] TOML configuration -- [x] Unit tests (105 tests passing) - -### Rust Prototype (`thop-rust/`) ✅ -- [x] Project setup with Cargo -- [x] Interactive mode with prompt -- [x] Local shell execution -- [x] SSH session support -- [x] Slash commands -- [x] Proxy mode -- [x] TOML configuration -- [x] Unit tests (32 tests passing) +Go and Rust prototypes were implemented and tested. **Go was selected** for faster development and better SSH library support. ### Evaluation Results ✅ -- [x] Binary size: Go 4.8MB, Rust 1.4MB -- [x] Both have similar code complexity -- [x] Both have fast startup (<100ms) -- [x] **Decision: Continue with Go** for faster development +- Binary size: Go 4.8MB, Rust 1.4MB +- Both have similar code complexity +- Both have fast startup (<100ms) +- **Decision: Continue with Go** for faster development --- diff --git a/thop-go/cmd/thop/main.go b/cmd/thop/main.go similarity index 100% rename from thop-go/cmd/thop/main.go rename to cmd/thop/main.go diff --git a/thop-go/configs/example.toml b/configs/example.toml similarity index 100% rename from thop-go/configs/example.toml rename to configs/example.toml diff --git a/thop-go/debian/changelog b/debian/changelog similarity index 100% rename from thop-go/debian/changelog rename to debian/changelog diff --git a/thop-go/debian/control b/debian/control similarity index 100% rename from thop-go/debian/control rename to debian/control diff --git a/thop-go/debian/copyright b/debian/copyright similarity index 100% rename from thop-go/debian/copyright rename to debian/copyright diff --git a/thop-go/debian/rules b/debian/rules similarity index 100% rename from thop-go/debian/rules rename to debian/rules diff --git a/thop-go/debian/source/format b/debian/source/format similarity index 100% rename from thop-go/debian/source/format rename to debian/source/format diff --git a/thop-go/debian/thop.1 b/debian/thop.1 similarity index 100% rename from thop-go/debian/thop.1 rename to debian/thop.1 diff --git a/thop-go/go.mod b/go.mod similarity index 100% rename from thop-go/go.mod rename to go.mod diff --git a/thop-go/go.sum b/go.sum similarity index 100% rename from thop-go/go.sum rename to go.sum diff --git a/thop-go/internal/cli/app.go b/internal/cli/app.go similarity index 100% rename from thop-go/internal/cli/app.go rename to internal/cli/app.go diff --git a/thop-go/internal/cli/app_test.go b/internal/cli/app_test.go similarity index 100% rename from thop-go/internal/cli/app_test.go rename to internal/cli/app_test.go diff --git a/thop-go/internal/cli/completions.go b/internal/cli/completions.go similarity index 100% rename from thop-go/internal/cli/completions.go rename to internal/cli/completions.go diff --git a/thop-go/internal/cli/completions_test.go b/internal/cli/completions_test.go similarity index 100% rename from thop-go/internal/cli/completions_test.go rename to internal/cli/completions_test.go diff --git a/thop-go/internal/cli/interactive.go b/internal/cli/interactive.go similarity index 99% rename from thop-go/internal/cli/interactive.go rename to internal/cli/interactive.go index c2d268b..94cee5e 100644 --- a/thop-go/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -57,7 +57,7 @@ func (a *App) runInteractive() error { // Ensure history directory exists historyDir := getHistoryDir() if historyDir != "" { - os.MkdirAll(historyDir, 0700) + _ = os.MkdirAll(historyDir, 0700) } // Get history file for initial session @@ -167,8 +167,8 @@ func (a *App) runInteractive() error { // Check for slash commands if strings.HasPrefix(input, "/") { - if err := a.handleSlashCommand(input); err != nil { - a.outputError(err) + if cmdErr := a.handleSlashCommand(input); cmdErr != nil { + a.outputError(cmdErr) } continue } @@ -214,7 +214,7 @@ func (a *App) executeWithSignalForwarding(cmd string) (*session.ExecuteResult, e // Ctrl+C received - cancel the context cancel() case <-ctx.Done(): - // Context already cancelled + // Context already canceled } }() @@ -251,8 +251,8 @@ func (a *App) runInteractiveSimple() error { } if strings.HasPrefix(input, "/") { - if err := a.handleSlashCommand(input); err != nil { - a.outputError(err) + if cmdErr := a.handleSlashCommand(input); cmdErr != nil { + a.outputError(cmdErr) } continue } @@ -663,7 +663,7 @@ func (a *App) cmdClose(name string) error { // Switch to local if we closed the active session if a.sessions.GetActiveSessionName() == name { - a.sessions.SetActiveSession("local") + _ = a.sessions.SetActiveSession("local") fmt.Println("Switched to local") } diff --git a/thop-go/internal/cli/interactive_test.go b/internal/cli/interactive_test.go similarity index 97% rename from thop-go/internal/cli/interactive_test.go rename to internal/cli/interactive_test.go index 6e53414..f2159f3 100644 --- a/thop-go/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -574,8 +574,8 @@ func TestHandleSlashCommandRead(t *testing.T) { tmpDir := t.TempDir() testFile := tmpDir + "/testfile.txt" testContent := "Hello, World!" - if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { - t.Fatalf("failed to create test file: %v", err) + if writeErr := os.WriteFile(testFile, []byte(testContent), 0644); writeErr != nil { + t.Fatalf("failed to create test file: %v", writeErr) } // Capture stdout @@ -696,7 +696,7 @@ func TestHandleSlashCommandEnv(t *testing.T) { // Set env oldStdout = os.Stdout - r, w, _ = os.Pipe() + _, w, _ = os.Pipe() os.Stdout = w err = app.handleSlashCommand("/env TEST_VAR=test_value") @@ -726,7 +726,7 @@ func TestParseFileSpec(t *testing.T) { {"server1:/etc/config", "server1", "/etc/config"}, {"/absolute/path", "", "/absolute/path"}, {"relative/path", "", "relative/path"}, - {"C:/Windows/path", "", "C:/Windows/path"}, // Windows path + {"C:/Windows/path", "", "C:/Windows/path"}, // Windows path {"D:\\Windows\\path", "", "D:\\Windows\\path"}, // Windows path with backslash } @@ -835,6 +835,23 @@ func TestHandleSlashCommandBg(t *testing.T) { if !strings.Contains(output, "[1] Started in background") { t.Errorf("expected 'Started in background' message, got: %s", output) } + + // Wait for the background job to complete to avoid race conditions + // with subsequent tests that may modify os.Stdout + for i := 0; i < 50; i++ { // Wait up to 5 seconds + app.bgJobsMu.RLock() + job, ok := app.bgJobs[1] + status := "" + if ok { + status = job.Status + } + app.bgJobsMu.RUnlock() + + if status != "running" { + break + } + time.Sleep(100 * time.Millisecond) + } } func TestHandleSlashCommandJobs(t *testing.T) { diff --git a/thop-go/internal/cli/mcp.go b/internal/cli/mcp.go similarity index 99% rename from thop-go/internal/cli/mcp.go rename to internal/cli/mcp.go index 6b6fce0..db9178a 100644 --- a/thop-go/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -14,4 +14,4 @@ func (a *App) runMCP() error { // Run the server (blocks until stopped) return server.Run() -} \ No newline at end of file +} diff --git a/thop-go/internal/cli/proxy.go b/internal/cli/proxy.go similarity index 100% rename from thop-go/internal/cli/proxy.go rename to internal/cli/proxy.go diff --git a/thop-go/internal/cli/proxy_test.go b/internal/cli/proxy_test.go similarity index 100% rename from thop-go/internal/cli/proxy_test.go rename to internal/cli/proxy_test.go diff --git a/thop-go/internal/config/config.go b/internal/config/config.go similarity index 100% rename from thop-go/internal/config/config.go rename to internal/config/config.go diff --git a/thop-go/internal/config/config_test.go b/internal/config/config_test.go similarity index 100% rename from thop-go/internal/config/config_test.go rename to internal/config/config_test.go diff --git a/thop-go/internal/logger/logger.go b/internal/logger/logger.go similarity index 99% rename from thop-go/internal/logger/logger.go rename to internal/logger/logger.go index 7264a1f..da50eae 100644 --- a/thop-go/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -148,7 +148,7 @@ func (l *Logger) log(level Level, format string, args ...interface{}) { } line := fmt.Sprintf("%s [%s] %s%s\n", timestamp, level.String(), prefix, msg) - l.output.Write([]byte(line)) + _, _ = l.output.Write([]byte(line)) } // Debug logs a debug message diff --git a/thop-go/internal/logger/logger_test.go b/internal/logger/logger_test.go similarity index 100% rename from thop-go/internal/logger/logger_test.go rename to internal/logger/logger_test.go diff --git a/thop-go/internal/mcp/errors.go b/internal/mcp/errors.go similarity index 82% rename from thop-go/internal/mcp/errors.go rename to internal/mcp/errors.go index ed81077..3f110aa 100644 --- a/thop-go/internal/mcp/errors.go +++ b/internal/mcp/errors.go @@ -14,28 +14,28 @@ const ( ErrorCannotCloseLocal ErrorCode = "CANNOT_CLOSE_LOCAL" // Connection errors - ErrorConnectionFailed ErrorCode = "CONNECTION_FAILED" - ErrorAuthFailed ErrorCode = "AUTH_FAILED" - ErrorAuthKeyFailed ErrorCode = "AUTH_KEY_FAILED" - ErrorAuthPasswordFailed ErrorCode = "AUTH_PASSWORD_FAILED" - ErrorHostKeyUnknown ErrorCode = "HOST_KEY_UNKNOWN" - ErrorHostKeyMismatch ErrorCode = "HOST_KEY_MISMATCH" - ErrorConnectionTimeout ErrorCode = "CONNECTION_TIMEOUT" - ErrorConnectionRefused ErrorCode = "CONNECTION_REFUSED" + ErrorConnectionFailed ErrorCode = "CONNECTION_FAILED" + ErrorAuthFailed ErrorCode = "AUTH_FAILED" + ErrorAuthKeyFailed ErrorCode = "AUTH_KEY_FAILED" + ErrorAuthPasswordFailed ErrorCode = "AUTH_PASSWORD_FAILED" + ErrorHostKeyUnknown ErrorCode = "HOST_KEY_UNKNOWN" + ErrorHostKeyMismatch ErrorCode = "HOST_KEY_MISMATCH" + ErrorConnectionTimeout ErrorCode = "CONNECTION_TIMEOUT" + ErrorConnectionRefused ErrorCode = "CONNECTION_REFUSED" // Command execution errors - ErrorCommandFailed ErrorCode = "COMMAND_FAILED" - ErrorCommandTimeout ErrorCode = "COMMAND_TIMEOUT" - ErrorCommandNotFound ErrorCode = "COMMAND_NOT_FOUND" - ErrorPermissionDenied ErrorCode = "PERMISSION_DENIED" + ErrorCommandFailed ErrorCode = "COMMAND_FAILED" + ErrorCommandTimeout ErrorCode = "COMMAND_TIMEOUT" + ErrorCommandNotFound ErrorCode = "COMMAND_NOT_FOUND" + ErrorPermissionDenied ErrorCode = "PERMISSION_DENIED" // Parameter errors - ErrorInvalidParameter ErrorCode = "INVALID_PARAMETER" - ErrorMissingParameter ErrorCode = "MISSING_PARAMETER" + ErrorInvalidParameter ErrorCode = "INVALID_PARAMETER" + ErrorMissingParameter ErrorCode = "MISSING_PARAMETER" // Feature errors - ErrorNotImplemented ErrorCode = "NOT_IMPLEMENTED" - ErrorOperationFailed ErrorCode = "OPERATION_FAILED" + ErrorNotImplemented ErrorCode = "NOT_IMPLEMENTED" + ErrorOperationFailed ErrorCode = "OPERATION_FAILED" ) // MCPError represents a structured error for MCP responses diff --git a/thop-go/internal/mcp/errors_test.go b/internal/mcp/errors_test.go similarity index 95% rename from thop-go/internal/mcp/errors_test.go rename to internal/mcp/errors_test.go index f150afd..077c50e 100644 --- a/thop-go/internal/mcp/errors_test.go +++ b/internal/mcp/errors_test.go @@ -11,13 +11,13 @@ func TestMCPError_Error(t *testing.T) { expected string }{ { - name: "basic error", - err: NewMCPError(ErrorSessionNotFound, "Session 'prod' not found"), + name: "basic error", + err: NewMCPError(ErrorSessionNotFound, "Session 'prod' not found"), expected: "[SESSION_NOT_FOUND] Session 'prod' not found", }, { - name: "error with session", - err: NewMCPError(ErrorConnectionFailed, "Connection failed").WithSession("prod"), + name: "error with session", + err: NewMCPError(ErrorConnectionFailed, "Connection failed").WithSession("prod"), expected: "[CONNECTION_FAILED] Connection failed (session: prod)", }, { diff --git a/thop-go/internal/mcp/handlers.go b/internal/mcp/handlers.go similarity index 99% rename from thop-go/internal/mcp/handlers.go rename to internal/mcp/handlers.go index b024b20..984a995 100644 --- a/thop-go/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -134,7 +134,6 @@ func (s *Server) handleToolsList(ctx context.Context, params json.RawMessage) (i Required: []string{"command"}, }, }, - } return map[string]interface{}{ @@ -264,8 +263,6 @@ func (s *Server) handleResourceRead(ctx context.Context, params json.RawMessage) }, nil } - - // handlePing handles ping requests func (s *Server) handlePing(ctx context.Context, params json.RawMessage) (interface{}, error) { return map[string]interface{}{ @@ -294,4 +291,4 @@ func (s *Server) handleProgress(ctx context.Context, params json.RawMessage) (in progressParams.Total) return nil, nil -} \ No newline at end of file +} diff --git a/thop-go/internal/mcp/protocol.go b/internal/mcp/protocol.go similarity index 90% rename from thop-go/internal/mcp/protocol.go rename to internal/mcp/protocol.go index 85e4bb1..41635ad 100644 --- a/thop-go/internal/mcp/protocol.go +++ b/internal/mcp/protocol.go @@ -14,10 +14,10 @@ type JSONRPCMessage struct { // JSONRPCResponse represents a JSON-RPC 2.0 response type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - ID interface{} `json:"id"` - Result interface{} `json:"result,omitempty"` - Error *JSONRPCError `json:"error,omitempty"` + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result interface{} `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` } // JSONRPCError represents a JSON-RPC 2.0 error @@ -110,16 +110,16 @@ type Tool struct { // InputSchema represents the JSON schema for tool input type InputSchema struct { - Type string `json:"type"` - Properties map[string]Property `json:"properties"` - Required []string `json:"required,omitempty"` + Type string `json:"type"` + Properties map[string]Property `json:"properties"` + Required []string `json:"required,omitempty"` } // Property represents a JSON schema property type Property struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` Default interface{} `json:"default,omitempty"` } @@ -191,14 +191,14 @@ type PromptGetParams struct { // PromptGetResult represents the result of getting a prompt type PromptGetResult struct { - Description string `json:"description,omitempty"` + Description string `json:"description,omitempty"` Messages []PromptMessage `json:"messages"` } // PromptMessage represents a message in a prompt type PromptMessage struct { - Role string `json:"role"` - Content Content `json:"content"` + Role string `json:"role"` + Content Content `json:"content"` } // ProgressParams represents parameters for progress notifications @@ -214,4 +214,4 @@ type LogParams struct { Logger string `json:"logger,omitempty"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` -} \ No newline at end of file +} diff --git a/thop-go/internal/mcp/server.go b/internal/mcp/server.go similarity index 97% rename from thop-go/internal/mcp/server.go rename to internal/mcp/server.go index 5425f99..bcad326 100644 --- a/thop-go/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -80,7 +80,7 @@ func (s *Server) registerHandlers() { s.handlers["ping"] = s.handlePing // Notification handlers - s.handlers["cancelled"] = s.handleCancelled + s.handlers["cancelled"] = s.handleCancelled // nolint:misspell // MCP protocol standard s.handlers["progress"] = s.handleProgress } @@ -103,7 +103,7 @@ func (s *Server) Run() error { if err := s.handleMessage(line); err != nil { logger.Error("Error handling message: %v", err) // Send error response - s.sendError(nil, -32603, "Internal error", err.Error()) + _ = s.sendError(nil, -32603, "Internal error", err.Error()) } } } @@ -263,4 +263,4 @@ func (s *Server) sendNotification(method string, params interface{}) error { } return nil -} \ No newline at end of file +} diff --git a/thop-go/internal/mcp/server_test.go b/internal/mcp/server_test.go similarity index 96% rename from thop-go/internal/mcp/server_test.go rename to internal/mcp/server_test.go index 5a43f02..3889bac 100644 --- a/thop-go/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -65,8 +65,8 @@ func TestMCPServer_Initialize(t *testing.T) { } var response JSONRPCResponse - if err := json.Unmarshal(responseData[:len(responseData)-1], &response); err != nil { // Remove trailing newline - t.Fatal(err) + if unmarshalErr := json.Unmarshal(responseData[:len(responseData)-1], &response); unmarshalErr != nil { // Remove trailing newline + t.Fatal(unmarshalErr) } // Check response @@ -306,7 +306,6 @@ func TestMCPServer_ResourcesList(t *testing.T) { } } - func TestMCPServer_Ping(t *testing.T) { // Create test configuration cfg := &config.Config{ @@ -366,7 +365,7 @@ func TestMCPServer_JSONRPCParsing(t *testing.T) { }, { name: "notification (no ID)", - input: `{"jsonrpc":"2.0","method":"cancelled"}`, + input: `{"jsonrpc":"2.0","method":"cancelled"}`, // nolint:misspell // MCP protocol standard wantErr: false, }, } @@ -410,6 +409,7 @@ func TestMCPServer_JSONRPCParsing(t *testing.T) { }) } } + // Test helper function func createTestServer() *Server { cfg := &config.Config{ @@ -424,8 +424,8 @@ func createTestServer() *Server { func TestMCPServer_ToolCall_Execute(t *testing.T) { srv := createTestServer() tests := []struct { - name string - params string + name string + params string wantErr bool }{ {"valid", `{"name":"execute","arguments":{"command":"echo test"}}`, false}, @@ -444,7 +444,10 @@ func TestMCPServer_ToolCall_Execute(t *testing.T) { func TestMCPServer_ResourceRead(t *testing.T) { srv := createTestServer() - tests := []struct{ uri string; wantErr bool }{ + tests := []struct { + uri string + wantErr bool + }{ {"session://active", false}, {"session://all", false}, {"config://thop", false}, @@ -461,7 +464,6 @@ func TestMCPServer_ResourceRead(t *testing.T) { } } - func TestMCPServer_Notifications(t *testing.T) { srv := createTestServer() if _, err := srv.handleInitialized(context.Background(), nil); err != nil { @@ -479,13 +481,13 @@ func TestMCPServer_SendMethods(t *testing.T) { srv := createTestServer() buf := &bytes.Buffer{} srv.SetIO(nil, buf) - + if err := srv.sendError(1, -32600, "test", "data"); err != nil { t.Error(err) } buf.Reset() - - if err := srv.sendNotification("test", map[string]string{"k":"v"}); err != nil { + + if err := srv.sendNotification("test", map[string]string{"k": "v"}); err != nil { t.Error(err) } } @@ -494,12 +496,12 @@ func TestMCPServer_Errors(t *testing.T) { srv := createTestServer() buf := &bytes.Buffer{} srv.SetIO(nil, buf) - + msg := &JSONRPCMessage{JSONRPC: "2.0", Method: "unknown", ID: 1} if err := srv.handleRequest(msg); err != nil { t.Error(err) } - + var resp JSONRPCResponse json.Unmarshal(buf.Bytes()[:buf.Len()-1], &resp) if resp.Error == nil || resp.Error.Code != -32601 { @@ -513,7 +515,7 @@ func TestMCPServer_Stop(t *testing.T) { select { case <-srv.ctx.Done(): default: - t.Error("Context should be cancelled") + t.Error("Context should be canceled") // nolint:misspell } } @@ -527,8 +529,8 @@ func TestJSONRPCError_Error(t *testing.T) { func TestMCPServer_ToolCall_Connect(t *testing.T) { srv := createTestServer() tests := []struct { - name string - params string + name string + params string wantErr bool }{ {"missing session", `{"name":"connect","arguments":{}}`, true}, @@ -547,8 +549,8 @@ func TestMCPServer_ToolCall_Connect(t *testing.T) { func TestMCPServer_ToolCall_Switch(t *testing.T) { srv := createTestServer() tests := []struct { - name string - params string + name string + params string wantErr bool }{ {"missing session", `{"name":"switch","arguments":{}}`, true}, @@ -568,8 +570,8 @@ func TestMCPServer_ToolCall_Switch(t *testing.T) { func TestMCPServer_ToolCall_Close(t *testing.T) { srv := createTestServer() tests := []struct { - name string - params string + name string + params string wantErr bool }{ {"missing session", `{"name":"close","arguments":{}}`, true}, @@ -615,7 +617,6 @@ func TestMCPServer_ResourceRead_InvalidParamsError(t *testing.T) { } } - func TestMCPServer_HandleInitialize_InvalidParamsError(t *testing.T) { srv := createTestServer() _, err := srv.handleInitialize(context.Background(), json.RawMessage(`{invalid}`)) diff --git a/thop-go/internal/mcp/tools.go b/internal/mcp/tools.go similarity index 95% rename from thop-go/internal/mcp/tools.go rename to internal/mcp/tools.go index f88ef9e..31bec66 100644 --- a/thop-go/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/scottgl9/thop/internal/logger" "github.com/scottgl9/thop/internal/session" ) @@ -284,20 +283,6 @@ func (s *Server) toolExecute(ctx context.Context, args map[string]interface{}) ( // Helper functions -// errorResult creates an error tool result -func (s *Server) errorResult(message string) interface{} { - logger.Debug("Tool error: %s", message) - return ToolCallResult{ - Content: []Content{ - { - Type: "text", - Text: message, - }, - }, - IsError: true, - } -} - // Resource helper functions // getActiveSessionResource returns the active session as a JSON resource @@ -309,10 +294,10 @@ func (s *Server) getActiveSessionResource() (string, error) { // Create session info info := map[string]interface{}{ - "name": sess.Name(), - "type": sess.Type(), - "connected": sess.IsConnected(), - "cwd": sess.GetCWD(), + "name": sess.Name(), + "type": sess.Type(), + "connected": sess.IsConnected(), + "cwd": sess.GetCWD(), "environment": sess.GetEnv(), } @@ -362,4 +347,4 @@ func (s *Server) getStateResource() (string, error) { } return string(data), nil -} \ No newline at end of file +} diff --git a/thop-go/internal/restriction/restriction.go b/internal/restriction/restriction.go similarity index 100% rename from thop-go/internal/restriction/restriction.go rename to internal/restriction/restriction.go diff --git a/thop-go/internal/restriction/restriction_test.go b/internal/restriction/restriction_test.go similarity index 100% rename from thop-go/internal/restriction/restriction_test.go rename to internal/restriction/restriction_test.go diff --git a/thop-go/internal/session/local.go b/internal/session/local.go similarity index 94% rename from thop-go/internal/session/local.go rename to internal/session/local.go index cf66bc9..91c36e7 100644 --- a/thop-go/internal/session/local.go +++ b/internal/session/local.go @@ -179,7 +179,7 @@ func (s *LocalSession) ExecuteWithContext(ctx context.Context, cmdStr string) (* } if err != nil { - // Check if context was cancelled (user interrupt) + // Check if context was canceled (user interrupt) if ctx.Err() == context.Canceled { logger.Debug("local command interrupted on %q", s.name) return &ExecuteResult{ @@ -248,16 +248,16 @@ func (s *LocalSession) ExecuteInteractive(cmdStr string) (int, error) { // Initial resize if term.IsTerminal(fd) { - if err := pty.InheritSize(os.Stdin, ptmx); err != nil { - logger.Debug("Failed to set initial PTY size: %v", err) + if resizeErr := pty.InheritSize(os.Stdin, ptmx); resizeErr != nil { + logger.Debug("Failed to set initial PTY size: %v", resizeErr) } } // Start goroutine to handle resize events go func() { for range sigwinchCh { - if err := pty.InheritSize(os.Stdin, ptmx); err != nil { - logger.Debug("Failed to resize PTY: %v", err) + if resizeErr := pty.InheritSize(os.Stdin, ptmx); resizeErr != nil { + logger.Debug("Failed to resize PTY: %v", resizeErr) } } }() @@ -274,7 +274,7 @@ func (s *LocalSession) ExecuteInteractive(cmdStr string) (int, error) { // Restore terminal on exit defer func() { if oldState != nil { - term.Restore(fd, oldState) + _ = term.Restore(fd, oldState) } }() @@ -300,9 +300,9 @@ func (s *LocalSession) ExecuteInteractive(cmdStr string) (int, error) { for { // Wait for input on stdin or interrupt pipe - n, err := unix.Poll(pollFds, -1) - if err != nil { - if err == syscall.EINTR { + n, pollErr := unix.Poll(pollFds, -1) + if pollErr != nil { + if pollErr == syscall.EINTR { continue // Interrupted by signal, retry } return @@ -318,11 +318,11 @@ func (s *LocalSession) ExecuteInteractive(cmdStr string) (int, error) { // Check if stdin has data if pollFds[0].Revents&unix.POLLIN != 0 { - nr, err := os.Stdin.Read(buf) - if err != nil || nr == 0 { + nr, readErr := os.Stdin.Read(buf) + if readErr != nil || nr == 0 { return } - if _, err := ptmx.Write(buf[:nr]); err != nil { + if _, writeErr := ptmx.Write(buf[:nr]); writeErr != nil { return } } @@ -335,10 +335,10 @@ func (s *LocalSession) ExecuteInteractive(cmdStr string) (int, error) { }() // Copy PTY to stdout - io.Copy(os.Stdout, ptmx) + _, _ = io.Copy(os.Stdout, ptmx) // Signal stdin goroutine to exit - interruptW.Write([]byte{0}) + _, _ = interruptW.Write([]byte{0}) interruptW.Close() interruptR.Close() diff --git a/thop-go/internal/session/local_test.go b/internal/session/local_test.go similarity index 98% rename from thop-go/internal/session/local_test.go rename to internal/session/local_test.go index 2e7c314..5955144 100644 --- a/thop-go/internal/session/local_test.go +++ b/internal/session/local_test.go @@ -33,10 +33,7 @@ func TestNewLocalSessionDefaultShell(t *testing.T) { session := NewLocalSession("test", "") // Should use SHELL env or /bin/sh - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/sh" - } + _ = os.Getenv("SHELL") // The shell field is private, but we can test behavior through Execute result, err := session.Execute("echo $0") @@ -172,7 +169,7 @@ func TestLocalSessionCD(t *testing.T) { } // cd to home - result, err = session.Execute("cd") + _, err = session.Execute("cd") if err != nil { t.Fatalf("Execute cd home failed: %v", err) } @@ -184,7 +181,7 @@ func TestLocalSessionCD(t *testing.T) { // cd with ~ session.Execute("cd /tmp") - result, err = session.Execute("cd ~") + _, err = session.Execute("cd ~") if err != nil { t.Fatalf("Execute cd ~ failed: %v", err) } diff --git a/thop-go/internal/session/manager.go b/internal/session/manager.go similarity index 98% rename from thop-go/internal/session/manager.go rename to internal/session/manager.go index 7779cda..2869450 100644 --- a/thop-go/internal/session/manager.go +++ b/internal/session/manager.go @@ -227,7 +227,7 @@ func (m *Manager) SetActiveSession(name string) error { // Persist to state file if m.state != nil { - m.state.SetActiveSession(name) + _ = m.state.SetActiveSession(name) } return nil @@ -257,7 +257,7 @@ func (m *Manager) Connect(name string) error { // Update state if m.state != nil { - m.state.SetSessionConnected(name, true) + _ = m.state.SetSessionConnected(name, true) } // Restore environment from state for SSH sessions @@ -289,7 +289,7 @@ func (m *Manager) Disconnect(name string) error { // Update state if m.state != nil { - m.state.SetSessionConnected(name, false) + _ = m.state.SetSessionConnected(name, false) } if err != nil { @@ -330,7 +330,7 @@ func (m *Manager) ExecuteWithContext(ctx context.Context, cmd string) (*ExecuteR logger.Debug("executing on session %q: %s", session.Name(), cmd) result, err := session.ExecuteWithContext(ctx, cmd) - // For SSH sessions, try to reconnect on connection errors (but not if context was cancelled) + // For SSH sessions, try to reconnect on connection errors (but not if context was canceled) if err != nil && session.Type() == "ssh" && ctx.Err() == nil { if sessionErr, ok := err.(*Error); ok && sessionErr.Retryable { if sessionErr.Code == ErrSessionDisconnected || sessionErr.Code == ErrConnectionFailed { @@ -347,7 +347,7 @@ func (m *Manager) ExecuteWithContext(ctx context.Context, cmd string) (*ExecuteR // Update cwd in state if successful if err == nil && m.state != nil { - m.state.SetSessionCWD(session.Name(), session.GetCWD()) + _ = m.state.SetSessionCWD(session.Name(), session.GetCWD()) } if err != nil { @@ -386,7 +386,7 @@ func (m *Manager) attemptReconnect(session Session) error { // Update state on successful reconnection if m.state != nil { - m.state.SetSessionConnected(session.Name(), true) + _ = m.state.SetSessionConnected(session.Name(), true) } // Restore environment from state @@ -431,7 +431,7 @@ func (m *Manager) SetSessionEnv(key, value string) error { // Persist to state if m.state != nil { - m.state.SetSessionEnv(session.Name(), key, value) + _ = m.state.SetSessionEnv(session.Name(), key, value) } logger.Debug("set environment %s=%s on session %q", key, value, session.Name()) diff --git a/thop-go/internal/session/manager_test.go b/internal/session/manager_test.go similarity index 100% rename from thop-go/internal/session/manager_test.go rename to internal/session/manager_test.go diff --git a/thop-go/internal/session/session.go b/internal/session/session.go similarity index 100% rename from thop-go/internal/session/session.go rename to internal/session/session.go diff --git a/thop-go/internal/session/ssh.go b/internal/session/ssh.go similarity index 91% rename from thop-go/internal/session/ssh.go rename to internal/session/ssh.go index 495cce3..d17b8b3 100644 --- a/thop-go/internal/session/ssh.go +++ b/internal/session/ssh.go @@ -24,23 +24,23 @@ import ( // SSHSession represents an SSH session to a remote host type SSHSession struct { - name string - host string - port int - user string - keyFile string - password string // Password for authentication (set via /auth command) - jumpHost string // Jump host for ProxyJump (format: user@host:port or just host) - agentForwarding bool // Whether to forward SSH agent to remote - insecureIgnoreHostKey bool // Skip host key verification (for testing only) - client *ssh.Client - jumpClient *ssh.Client // Jump host client (if using jump host) - cwd string - env map[string]string - connected bool - connectTimeout time.Duration - commandTimeout time.Duration - startupCommands []string + name string + host string + port int + user string + keyFile string + password string // Password for authentication (set via /auth command) + jumpHost string // Jump host for ProxyJump (format: user@host:port or just host) + agentForwarding bool // Whether to forward SSH agent to remote + insecureIgnoreHostKey bool // Skip host key verification (for testing only) + client *ssh.Client + jumpClient *ssh.Client // Jump host client (if using jump host) + cwd string + env map[string]string + connected bool + connectTimeout time.Duration + commandTimeout time.Duration + startupCommands []string } // SSHConfig contains SSH session configuration @@ -97,11 +97,11 @@ func NewSSHSession(cfg SSHConfig) *SSHSession { logger.Warn("SSH session %q: password file %s has insecure permissions %o (should be 0600)", cfg.Name, pwFile, mode) } else { // Read password from file - if data, err := os.ReadFile(pwFile); err == nil { + if data, readErr := os.ReadFile(pwFile); readErr == nil { password = strings.TrimSpace(string(data)) logger.Debug("SSH session %q: password loaded from file %s", cfg.Name, pwFile) } else { - logger.Warn("SSH session %q: failed to read password file %s: %v", cfg.Name, pwFile, err) + logger.Warn("SSH session %q: failed to read password file %s: %v", cfg.Name, pwFile, readErr) } } } else { @@ -363,11 +363,41 @@ func (s *SSHSession) CheckConnection() bool { // Reconnect attempts to reconnect the SSH session func (s *SSHSession) Reconnect() error { + // Save current state before disconnecting + savedCwd := s.cwd + savedEnv := make(map[string]string) + for k, v := range s.env { + savedEnv[k] = v + } + // Close any existing connection - s.Disconnect() + _ = s.Disconnect() // Attempt to connect - return s.Connect() + err := s.Connect() + if err != nil { + return err + } + + // Restore saved state + if savedCwd != "" && savedCwd != "~" { + // Don't use Execute() as it would run the command + // Just set the cwd directly and verify it exists + result, cdErr := s.executeRaw(fmt.Sprintf("cd %s && pwd", savedCwd)) + if cdErr == nil && result.ExitCode == 0 { + s.cwd = strings.TrimSpace(result.Stdout) + logger.Debug("SSH restored cwd to %s", s.cwd) + } else { + logger.Debug("SSH could not restore cwd %s: %v", savedCwd, cdErr) + } + } + + // Restore environment + for k, v := range savedEnv { + s.env[k] = v + } + + return nil } // Execute runs a command over SSH @@ -461,10 +491,10 @@ func (s *SSHSession) executeRawWithContext(ctx context.Context, cmdStr string) ( case runErr = <-done: // Command completed case <-ctx.Done(): - // Context cancelled (user interrupt) + // Context canceled (user interrupt) logger.Debug("SSH command interrupted on %q", s.name) // Send SIGINT to the remote process - session.Signal(ssh.SIGINT) + _ = session.Signal(ssh.SIGINT) // Give a brief moment for clean termination time.Sleep(100 * time.Millisecond) session.Close() @@ -523,8 +553,8 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { // Request agent forwarding if enabled if s.agentForwarding { - if err := agent.RequestAgentForwarding(session); err != nil { - logger.Debug("SSH agent forwarding request failed: %v", err) + if agentErr := agent.RequestAgentForwarding(session); agentErr != nil { + logger.Debug("SSH agent forwarding request failed: %v", agentErr) } } @@ -532,7 +562,7 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { fd := int(os.Stdin.Fd()) width, height := 80, 24 // defaults if term.IsTerminal(fd) { - if w, h, err := term.GetSize(fd); err == nil { + if w, h, sizeErr := term.GetSize(fd); sizeErr == nil { width, height = w, h } } @@ -549,8 +579,8 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { ssh.TTY_OP_OSPEED: 14400, // Output speed = 14.4kbaud } - if err := session.RequestPty(termType, height, width, modes); err != nil { - return 1, fmt.Errorf("failed to request PTY: %w", err) + if ptyErr := session.RequestPty(termType, height, width, modes); ptyErr != nil { + return 1, fmt.Errorf("failed to request PTY: %w", ptyErr) } // Get pipes for stdin/stdout @@ -576,7 +606,7 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { // Restore terminal on exit defer func() { if oldState != nil { - term.Restore(fd, oldState) + _ = term.Restore(fd, oldState) } }() @@ -588,8 +618,8 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { // Start goroutine to handle resize events go func() { for range sigwinchCh { - if w, h, err := term.GetSize(fd); err == nil { - session.WindowChange(h, w) + if w, h, resizeErr := term.GetSize(fd); resizeErr == nil { + _ = session.WindowChange(h, w) } } }() @@ -610,8 +640,8 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { fullCmd = envPrefix.String() + fullCmd // Start the command (non-blocking) - if err := session.Start(fullCmd); err != nil { - return 1, fmt.Errorf("failed to start command: %w", err) + if startErr := session.Start(fullCmd); startErr != nil { + return 1, fmt.Errorf("failed to start command: %w", startErr) } // Create interrupt pipe to signal stdin goroutine to exit @@ -637,9 +667,9 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { for { // Wait for input on stdin or interrupt pipe - n, err := unix.Poll(pollFds, -1) - if err != nil { - if err == syscall.EINTR { + n, pollErr := unix.Poll(pollFds, -1) + if pollErr != nil { + if pollErr == syscall.EINTR { continue // Interrupted by signal, retry } return @@ -655,11 +685,11 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { // Check if stdin has data if pollFds[0].Revents&unix.POLLIN != 0 { - nr, err := os.Stdin.Read(buf) - if err != nil || nr == 0 { + nr, readErr := os.Stdin.Read(buf) + if readErr != nil || nr == 0 { return } - if _, err := stdinPipe.Write(buf[:nr]); err != nil { + if _, writeErr := stdinPipe.Write(buf[:nr]); writeErr != nil { return } } @@ -672,10 +702,10 @@ func (s *SSHSession) ExecuteInteractive(cmdStr string) (int, error) { }() // Copy remote stdout to local stdout - io.Copy(os.Stdout, stdoutPipe) + _, _ = io.Copy(os.Stdout, stdoutPipe) // Signal stdin goroutine to exit - interruptW.Write([]byte{0}) + _, _ = interruptW.Write([]byte{0}) interruptW.Close() interruptR.Close() @@ -980,7 +1010,7 @@ func (s *SSHSession) FetchHostKey() (keyType string, fingerprint string, err err } // Do SSH handshake to get the host key - sshConn, _, _, err := ssh.NewClientConn(conn, addr, config) + sshConn, _, _, _ := ssh.NewClientConn(conn, addr, config) if sshConn != nil { sshConn.Close() } @@ -1025,7 +1055,7 @@ func (s *SSHSession) AddHostKey() error { return fmt.Errorf("failed to connect to %s: %w", addr, err) } - sshConn, _, _, err := ssh.NewClientConn(conn, addr, config) + sshConn, _, _, _ := ssh.NewClientConn(conn, addr, config) if sshConn != nil { sshConn.Close() } diff --git a/thop-go/internal/session/ssh_integration_test.go b/internal/session/ssh_integration_test.go similarity index 100% rename from thop-go/internal/session/ssh_integration_test.go rename to internal/session/ssh_integration_test.go diff --git a/thop-go/internal/session/ssh_test.go b/internal/session/ssh_test.go similarity index 98% rename from thop-go/internal/session/ssh_test.go rename to internal/session/ssh_test.go index e198e6b..8f5eb6e 100644 --- a/thop-go/internal/session/ssh_test.go +++ b/internal/session/ssh_test.go @@ -186,8 +186,8 @@ func TestSSHSessionBasicFields(t *testing.T) { if session.User() != "testuser" { t.Errorf("Expected user 'testuser', got %q", session.User()) } - if !session.IsConnected() == true { - // Not connected by default + if session.IsConnected() { + t.Error("session should not be connected by default") } } diff --git a/thop-go/internal/sshconfig/sshconfig.go b/internal/sshconfig/sshconfig.go similarity index 96% rename from thop-go/internal/sshconfig/sshconfig.go rename to internal/sshconfig/sshconfig.go index 92331d6..622f6e4 100644 --- a/thop-go/internal/sshconfig/sshconfig.go +++ b/internal/sshconfig/sshconfig.go @@ -9,13 +9,13 @@ import ( // HostConfig represents SSH configuration for a host type HostConfig struct { - Host string - HostName string - User string - Port string - IdentityFile string - ProxyJump string - ForwardAgent bool + Host string + HostName string + User string + Port string + IdentityFile string + ProxyJump string + ForwardAgent bool } // Config holds parsed SSH configuration diff --git a/thop-go/internal/sshconfig/sshconfig_test.go b/internal/sshconfig/sshconfig_test.go similarity index 100% rename from thop-go/internal/sshconfig/sshconfig_test.go rename to internal/sshconfig/sshconfig_test.go diff --git a/thop-go/internal/state/state.go b/internal/state/state.go similarity index 97% rename from thop-go/internal/state/state.go rename to internal/state/state.go index 19d88f0..3790200 100644 --- a/thop-go/internal/state/state.go +++ b/internal/state/state.go @@ -120,7 +120,7 @@ func (m *Manager) readWithLock() ([]byte, error) { if err := syscall.Flock(int(file.Fd()), syscall.LOCK_SH); err != nil { return nil, fmt.Errorf("failed to acquire read lock: %w", err) } - defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + defer func() { _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) }() return os.ReadFile(m.path) } @@ -143,7 +143,7 @@ func (m *Manager) writeWithLock(data []byte) error { if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { return fmt.Errorf("failed to acquire write lock: %w", err) } - defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + defer func() { _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) }() if _, err := file.Write(data); err != nil { return fmt.Errorf("failed to write state file: %w", err) diff --git a/thop-go/internal/state/state_test.go b/internal/state/state_test.go similarity index 100% rename from thop-go/internal/state/state_test.go rename to internal/state/state_test.go diff --git a/thop-go/.gitignore b/thop-go/.gitignore deleted file mode 100644 index b437fe2..0000000 --- a/thop-go/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -# Binaries -bin/ -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool -coverage.out -coverage.html - -# Dependency directories -vendor/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Debug -__debug_bin - -# Debian build artifacts -debian/.debhelper/ -debian/debhelper-build-stamp -debian/files -debian/*.substvars -debian/thop/ diff --git a/thop-rust/.gitignore b/thop-rust/.gitignore deleted file mode 100644 index 732c20c..0000000 --- a/thop-rust/.gitignore +++ /dev/null @@ -1,35 +0,0 @@ -# Build artifacts -target/ -debug/ -release/ - -# Cargo lock can be committed for binaries, but ignore for libraries -# Cargo.lock - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Backup files -*.bak -*.orig - -# Profiling -*.profraw -*.profdata -perf.data -perf.data.old -flamegraph.svg - -# Generated documentation -doc/ - -# Crash reports -*.pdb diff --git a/thop-rust/Cargo.lock b/thop-rust/Cargo.lock deleted file mode 100644 index 4c2244e..0000000 --- a/thop-rust/Cargo.lock +++ /dev/null @@ -1,964 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "cc" -version = "1.2.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.5.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indexmap" -version = "2.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "proc-macro2" -version = "1.0.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "ssh2" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" -dependencies = [ - "bitflags", - "libc", - "libssh2-sys", - "parking_lot", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thop" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "clap", - "dirs", - "indexmap", - "libc", - "regex", - "serde", - "serde_json", - "ssh2", - "tempfile", - "thiserror", - "toml", -] - -[[package]] -name = "toml" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" - -[[package]] -name = "zmij" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" diff --git a/thop-rust/Cargo.toml b/thop-rust/Cargo.toml deleted file mode 100644 index 4adbba2..0000000 --- a/thop-rust/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "thop" -version = "0.1.0" -edition = "2021" -description = "Terminal Hopper for Agents - seamless shell wrapper for SSH sessions" -authors = ["Scott Luu"] -license = "MIT" - -[dependencies] -# CLI argument parsing -clap = { version = "4.4.11", features = ["derive"] } - -# Configuration -toml = "0.7.8" -serde = { version = "1.0.195", features = ["derive"] } -serde_json = "1.0.111" - -# SSH -ssh2 = "0.9.4" - -# File system and paths -dirs = "5.0.1" - -# Error handling -thiserror = "1.0.56" -anyhow = "1.0.79" - -# Regex for command restriction patterns -regex = "1.10" - -# System -libc = "0.2.152" - -# Time/date -chrono = { version = "0.4.31", features = ["serde"] } - -# Pin indexmap to a version compatible with rustc 1.75 -indexmap = "2.2.6" - -[dev-dependencies] -tempfile = "3.9" - -[profile.release] -lto = true -strip = true -codegen-units = 1 -panic = "abort" - -[profile.dev] -opt-level = 0 -debug = true diff --git a/thop-rust/Makefile b/thop-rust/Makefile deleted file mode 100644 index 2a9426c..0000000 --- a/thop-rust/Makefile +++ /dev/null @@ -1,127 +0,0 @@ -# thop Makefile (Rust) - -BINARY_NAME=thop -BUILD_DIR=target - -# Install directory (can be overridden: make install PREFIX=/usr) -PREFIX?=/usr/local -BINDIR?=$(PREFIX)/bin - -.PHONY: all build release test clean fmt clippy check install uninstall build-linux build-darwin build-all - -all: build - -# Build debug binary -build: - cargo build - @echo "Binary built: $(BUILD_DIR)/debug/$(BINARY_NAME)" - -# Build release binary -release: - cargo build --release - @echo "Binary built: $(BUILD_DIR)/release/$(BINARY_NAME)" - @ls -lh $(BUILD_DIR)/release/$(BINARY_NAME) - -# Cross-compilation targets -build-linux: - cargo build --release --target x86_64-unknown-linux-gnu - @echo "Binary built: $(BUILD_DIR)/x86_64-unknown-linux-gnu/release/$(BINARY_NAME)" - -build-darwin: - cargo build --release --target x86_64-apple-darwin - cargo build --release --target aarch64-apple-darwin - @echo "Binaries built for macOS (Intel and Apple Silicon)" - -build-all: build-linux build-darwin - -# Run tests -test: - cargo test - -# Run tests with output -test-verbose: - cargo test -- --nocapture - -# Format code -fmt: - cargo fmt - -# Check formatting -fmt-check: - cargo fmt -- --check - -# Run clippy lints -clippy: - cargo clippy -- -D warnings - -# Check code (compile without producing binaries) -check: - cargo check - -# Clean build artifacts -clean: - cargo clean - -# Install binary to /usr/local/bin (or custom PREFIX) -install: release - @echo "Installing $(BINARY_NAME) to $(BINDIR)" - install -d $(BINDIR) - install -m 755 $(BUILD_DIR)/release/$(BINARY_NAME) $(BINDIR)/$(BINARY_NAME) - -# Uninstall binary -uninstall: - @echo "Removing $(BINARY_NAME) from $(BINDIR)" - rm -f $(BINDIR)/$(BINARY_NAME) - -# Run in debug mode -run: - cargo run - -# Run with proxy mode -run-proxy: - cargo run -- --proxy - -# Run with status -run-status: - cargo run -- --status - -# Build documentation -doc: - cargo doc --no-deps --open - -# Update dependencies -update: - cargo update - -# Show binary size -size: release - @echo "Binary size:" - @ls -lh $(BUILD_DIR)/release/$(BINARY_NAME) - @echo "" - @echo "Stripped size (estimate):" - @strip -o /tmp/$(BINARY_NAME)_stripped $(BUILD_DIR)/release/$(BINARY_NAME) - @ls -lh /tmp/$(BINARY_NAME)_stripped - @rm /tmp/$(BINARY_NAME)_stripped - -help: - @echo "Available targets:" - @echo " build - Build debug binary" - @echo " release - Build optimized release binary" - @echo " test - Run all tests" - @echo " test-verbose - Run tests with output" - @echo " fmt - Format code" - @echo " fmt-check - Check code formatting" - @echo " clippy - Run clippy lints" - @echo " check - Check code compilation" - @echo " clean - Remove build artifacts" - @echo " install - Install to /usr/local/bin (PREFIX=/usr/local)" - @echo " uninstall - Remove from /usr/local/bin" - @echo " run - Run in debug mode" - @echo " run-proxy - Run in proxy mode" - @echo " run-status - Run status command" - @echo " doc - Build and open documentation" - @echo " update - Update dependencies" - @echo " size - Show binary size" - @echo " build-linux - Build for Linux x86_64" - @echo " build-darwin - Build for macOS (Intel and Apple Silicon)" - @echo " build-all - Build for all platforms" diff --git a/thop-rust/src/cli/interactive.rs b/thop-rust/src/cli/interactive.rs deleted file mode 100644 index 2ef32e0..0000000 --- a/thop-rust/src/cli/interactive.rs +++ /dev/null @@ -1,907 +0,0 @@ -use std::fs; -use std::io::{self, BufRead, Write}; -use std::path::PathBuf; - -use crate::error::{Result, SessionError, ThopError}; -use crate::session::format_prompt; -use super::{print_slash_help, App}; - -/// Read password from terminal (with echo disabled if possible) -fn read_password(prompt: &str) -> io::Result { - print!("{}", prompt); - io::stdout().flush()?; - - // Try to read without echo using rpassword-like behavior - // For simplicity, we'll just read a line (a proper impl would disable echo) - let mut password = String::new(); - io::stdin().read_line(&mut password)?; - println!(); // Add newline after password entry - Ok(password.trim().to_string()) -} - -/// Run interactive mode -pub fn run_interactive(app: &mut App) -> Result<()> { - let stdin = io::stdin(); - let mut handle = stdin.lock(); - - if !app.args.quiet { - println!("thop - Terminal Hopper for Agents"); - println!("Type /help for available commands"); - println!(); - } - - loop { - // Print prompt - let session_name = app.sessions.get_active_session_name(); - let prompt = format_prompt(session_name); - print!("{}", prompt); - io::stdout().flush()?; - - // Read input - let mut input = String::new(); - if handle.read_line(&mut input)? == 0 { - // EOF (Ctrl+D) - println!(); - return Ok(()); - } - - let input = input.trim(); - if input.is_empty() { - continue; - } - - // Check for slash commands - if input.starts_with('/') { - if let Err(e) = handle_slash_command(app, input) { - app.output_error(&e); - } - continue; - } - - // Execute command - match app.sessions.execute(input) { - Ok(result) => { - if !result.stdout.is_empty() { - print!("{}", result.stdout); - if !result.stdout.ends_with('\n') { - println!(); - } - } - if !result.stderr.is_empty() { - eprint!("{}", result.stderr); - if !result.stderr.ends_with('\n') { - eprintln!(); - } - } - } - Err(e) => { - app.output_error(&e); - } - } - } -} - -/// Handle slash commands -fn handle_slash_command(app: &mut App, input: &str) -> Result<()> { - let parts: Vec<&str> = input.split_whitespace().collect(); - if parts.is_empty() { - return Ok(()); - } - - let cmd = parts[0].to_lowercase(); - let args = &parts[1..]; - - match cmd.as_str() { - "/help" | "/h" | "/?" => { - print_slash_help(); - Ok(()) - } - - "/status" | "/s" | "/sessions" | "/list" => { - app.print_status() - } - - "/connect" | "/c" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /connect ".to_string())); - } - cmd_connect(app, args[0]) - } - - "/switch" | "/sw" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /switch ".to_string())); - } - cmd_switch(app, args[0]) - } - - "/local" | "/l" => { - cmd_switch(app, "local") - } - - "/close" | "/disconnect" | "/d" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /close ".to_string())); - } - cmd_close(app, args[0]) - } - - "/exit" | "/quit" | "/q" => { - println!("Goodbye!"); - std::process::exit(0); - } - - "/env" => { - cmd_env(app, args) - } - - "/auth" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /auth ".to_string())); - } - cmd_auth(app, args[0]) - } - - "/read" | "/cat" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /read ".to_string())); - } - cmd_read(app, args[0]) - } - - "/write" => { - if args.len() < 2 { - return Err(ThopError::Other("usage: /write ".to_string())); - } - let path = args[0]; - let content = args[1..].join(" "); - cmd_write(app, path, &content) - } - - "/trust" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /trust ".to_string())); - } - cmd_trust(app, args[0]) - } - - "/add-session" | "/add" => { - if args.len() < 2 { - return Err(ThopError::Other("usage: /add-session [user]".to_string())); - } - let name = args[0]; - let host = args[1]; - let user = args.get(2).copied(); - cmd_add_session(app, name, host, user) - } - - "/bg" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /bg ".to_string())); - } - cmd_bg(app, &args.join(" ")) - } - - "/jobs" => { - cmd_jobs(app) - } - - "/fg" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /fg ".to_string())); - } - cmd_fg(app, args[0]) - } - - "/kill" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /kill ".to_string())); - } - cmd_kill_job(app, args[0]) - } - - "/copy" | "/cp" => { - if args.len() < 2 { - return Err(ThopError::Other( - "usage: /copy \n Examples:\n /copy local:/path/to/file remote:/path/to/file\n /copy remote:/path/to/file local:/path/to/file".to_string() - )); - } - cmd_copy(app, args[0], args[1]) - } - - "/shell" | "/sh" => { - if args.is_empty() { - return Err(ThopError::Other( - "usage: /shell \n Runs command with interactive support (vim, top, etc.)".to_string() - )); - } - cmd_shell(app, &args.join(" ")) - } - - _ => { - Err(ThopError::Other(format!( - "unknown command: {} (use /help for available commands)", - cmd - ))) - } - } -} - -/// Handle /env command - show or set environment variables -fn cmd_env(app: &mut App, args: &[&str]) -> Result<()> { - if args.is_empty() { - // Show all environment variables for active session - let session_name = app.sessions.get_active_session_name(); - if let Some(session) = app.sessions.get_session(session_name) { - let env = session.get_env(); - if env.is_empty() { - println!("No environment variables set for session '{}'", session_name); - } else { - println!("Environment variables for '{}':", session_name); - let mut keys: Vec<_> = env.keys().collect(); - keys.sort(); - for key in keys { - println!(" {}={}", key, env.get(key).unwrap()); - } - } - } - } else { - // Set environment variable - let arg = args.join(" "); - if let Some(pos) = arg.find('=') { - let key = &arg[..pos]; - let value = &arg[pos + 1..]; - let session_name = app.sessions.get_active_session_name().to_string(); - if let Some(session) = app.sessions.get_session_mut(&session_name) { - session.set_env(key, value); - println!("Set {}={}", key, value); - } - } else { - // Show specific variable - let session_name = app.sessions.get_active_session_name(); - if let Some(session) = app.sessions.get_session(session_name) { - let env = session.get_env(); - if let Some(value) = env.get(args[0]) { - println!("{}={}", args[0], value); - } else { - println!("{} is not set", args[0]); - } - } - } - } - Ok(()) -} - -/// Handle /auth command - set password for SSH session -fn cmd_auth(app: &mut App, name: &str) -> Result<()> { - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - - let session = app.sessions.get_session(name).unwrap(); - if session.session_type() == "local" { - return Err(ThopError::Other("Cannot set password for local session".to_string())); - } - - let password = read_password("Password: ") - .map_err(|e| ThopError::Other(format!("Failed to read password: {}", e)))?; - - if password.is_empty() { - return Err(ThopError::Other("Password cannot be empty".to_string())); - } - - app.sessions.set_session_password(name, &password)?; - println!("Password set for {}", name); - - Ok(()) -} - -/// Handle /connect command -fn cmd_connect(app: &mut App, name: &str) -> Result<()> { - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - - let session = app.sessions.get_session(name).unwrap(); - - if session.session_type() == "local" { - println!("Session '{}' is local, no connection needed", name); - return Ok(()); - } - - if session.is_connected() { - println!("Session '{}' is already connected", name); - return Ok(()); - } - - println!("Connecting to {}...", name); - app.sessions.connect(name)?; - println!("Connected to {}", name); - - Ok(()) -} - -/// Handle /switch command -fn cmd_switch(app: &mut App, name: &str) -> Result<()> { - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - - let session = app.sessions.get_session(name).unwrap(); - - // For SSH sessions, connect if not connected - if session.session_type() == "ssh" && !session.is_connected() { - println!("Connecting to {}...", name); - app.sessions.connect(name)?; - println!("Connected to {}", name); - } - - app.sessions.set_active_session(name)?; - - if !app.args.quiet { - println!("Switched to {}", name); - } - - Ok(()) -} - -/// Handle /close command -fn cmd_close(app: &mut App, name: &str) -> Result<()> { - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - - let session = app.sessions.get_session(name).unwrap(); - - if session.session_type() == "local" { - println!("Cannot close local session"); - return Ok(()); - } - - if !session.is_connected() { - println!("Session '{}' is not connected", name); - return Ok(()); - } - - app.sessions.disconnect(name)?; - println!("Disconnected from {}", name); - - // Switch to local if we closed the active session - if app.sessions.get_active_session_name() == name { - app.sessions.set_active_session("local")?; - println!("Switched to local"); - } - - Ok(()) -} - -/// Handle /read command - read file contents -fn cmd_read(app: &mut App, path: &str) -> Result<()> { - let session_name = app.sessions.get_active_session_name(); - let session = app.sessions.get_session(session_name).unwrap(); - - if session.session_type() == "local" { - // Local file read - let expanded_path = expand_path(path); - match fs::read_to_string(&expanded_path) { - Ok(content) => { - print!("{}", content); - if !content.ends_with('\n') { - println!(); - } - } - Err(e) => { - return Err(ThopError::Other(format!("Failed to read file: {}", e))); - } - } - } else { - // Remote file read via cat - let result = app.sessions.execute(&format!("cat {}", shell_escape(path)))?; - if result.exit_code != 0 { - return Err(ThopError::Other(format!( - "Failed to read file: {}", - result.stderr.trim() - ))); - } - print!("{}", result.stdout); - if !result.stdout.ends_with('\n') { - println!(); - } - } - - Ok(()) -} - -/// Handle /write command - write content to file -fn cmd_write(app: &mut App, path: &str, content: &str) -> Result<()> { - let session_name = app.sessions.get_active_session_name(); - let session = app.sessions.get_session(session_name).unwrap(); - - if session.session_type() == "local" { - // Local file write - let expanded_path = expand_path(path); - match fs::write(&expanded_path, content) { - Ok(_) => { - println!("Written {} bytes to {}", content.len(), path); - } - Err(e) => { - return Err(ThopError::Other(format!("Failed to write file: {}", e))); - } - } - } else { - // Remote file write via cat with heredoc - let cmd = format!( - "cat > {} << 'THOP_EOF'\n{}\nTHOP_EOF", - shell_escape(path), - content - ); - let result = app.sessions.execute(&cmd)?; - if result.exit_code != 0 { - return Err(ThopError::Other(format!( - "Failed to write file: {}", - result.stderr.trim() - ))); - } - println!("Written {} bytes to {}", content.len(), path); - } - - Ok(()) -} - -/// Handle /trust command - trust host key for SSH session -fn cmd_trust(app: &mut App, name: &str) -> Result<()> { - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - - let session = app.sessions.get_session(name).unwrap(); - if session.session_type() == "local" { - return Err(ThopError::Other("Cannot trust host key for local session".to_string())); - } - - // Get the host from the session - // For now, we'll use ssh-keyscan to fetch and add the key - // This requires knowing the host - we'd need to store it in the session - println!("To trust the host key for '{}', run:", name); - println!(" ssh-keyscan >> ~/.ssh/known_hosts"); - println!(); - println!("Or connect with ssh once to manually verify and add the key:"); - println!(" ssh "); - - Ok(()) -} - -/// Handle /add-session command - add new SSH session -fn cmd_add_session(app: &mut App, name: &str, host: &str, user: Option<&str>) -> Result<()> { - if app.sessions.has_session(name) { - return Err(ThopError::Other(format!("Session '{}' already exists", name))); - } - - let user = user.map(|s| s.to_string()).unwrap_or_else(|| { - std::env::var("USER").unwrap_or_else(|_| "root".to_string()) - }); - - // Add session to the manager - app.sessions.add_ssh_session(name, host, &user, 22)?; - - println!("Added SSH session '{}'", name); - println!(" Host: {}", host); - println!(" User: {}", user); - println!(" Port: 22"); - println!(); - println!("Use '/connect {}' to connect", name); - - Ok(()) -} - -/// Handle /bg command - run command in background -fn cmd_bg(app: &mut App, command: &str) -> Result<()> { - use std::thread; - use super::BackgroundJob; - - let session_name = app.sessions.get_active_session_name().to_string(); - - // Get next job ID - let job_id = { - let mut id = app.next_job_id.lock().unwrap(); - let current = *id; - *id += 1; - current - }; - - // Create the job - let job = BackgroundJob::new(job_id, command.to_string(), session_name.clone()); - - // Add to jobs map - { - let mut jobs = app.bg_jobs.write().unwrap(); - jobs.insert(job_id, job); - } - - println!("[{}] Started in background: {}", job_id, command); - - // Clone what we need for the thread - let bg_jobs = app.bg_jobs.clone(); - let cmd = command.to_string(); - - // Execute in a separate thread - // Note: This spawns a new session manager which isn't ideal but works for simple cases - let config = app.config.clone(); - thread::spawn(move || { - use crate::session::Manager as SessionManager; - use crate::state::Manager as StateManager; - - let state = StateManager::new(&config.settings.state_file); - let mut sessions = SessionManager::new(&config, Some(state)); - - // Try to set to same session (local should work) - let _ = sessions.set_active_session(&session_name); - - let result = sessions.execute(&cmd); - - // Update job with result - let mut jobs = bg_jobs.write().unwrap(); - if let Some(job) = jobs.get_mut(&job_id) { - job.end_time = Some(std::time::Instant::now()); - - match result { - Ok(exec_result) => { - job.status = "completed".to_string(); - job.stdout = exec_result.stdout; - job.stderr = exec_result.stderr; - job.exit_code = exec_result.exit_code; - } - Err(e) => { - job.status = "failed".to_string(); - job.stderr = e.to_string(); - job.exit_code = 1; - } - } - - let duration = job.end_time.unwrap().duration_since(job.start_time); - if job.status == "completed" { - println!("\n[{}] Done ({:.1?}): {}", job_id, duration, cmd); - } else { - println!("\n[{}] Failed ({:.1?}): {}", job_id, duration, cmd); - } - } - }); - - Ok(()) -} - -/// Handle /jobs command - list background jobs -fn cmd_jobs(app: &mut App) -> Result<()> { - let jobs = app.bg_jobs.read().unwrap(); - - if jobs.is_empty() { - println!("No background jobs"); - return Ok(()); - } - - println!("Background jobs:"); - for job in jobs.values() { - let status = match job.status.as_str() { - "running" => { - let duration = job.start_time.elapsed(); - format!("running ({:.0?})", duration) - } - "completed" => { - let duration = job.end_time.map(|e| e.duration_since(job.start_time)); - format!("completed (exit {}, {:.1?})", job.exit_code, duration.unwrap_or_default()) - } - "failed" => { - let duration = job.end_time.map(|e| e.duration_since(job.start_time)); - format!("failed ({:.1?})", duration.unwrap_or_default()) - } - _ => job.status.clone(), - }; - - let cmd_display = if job.command.len() > 40 { - format!("{}...", &job.command[..37]) - } else { - job.command.clone() - }; - - println!(" [{}] {:12} {} {}", job.id, job.session, status, cmd_display); - } - - Ok(()) -} - -/// Handle /fg command - wait for job and display output -fn cmd_fg(app: &mut App, job_id_str: &str) -> Result<()> { - use std::thread; - use std::time::Duration; - - let job_id: usize = job_id_str.parse() - .map_err(|_| ThopError::Other(format!("Invalid job ID: {}", job_id_str)))?; - - // Check if job exists - { - let jobs = app.bg_jobs.read().unwrap(); - if !jobs.contains_key(&job_id) { - return Err(ThopError::Other(format!("Job {} not found", job_id))); - } - } - - // Wait for job if still running - loop { - { - let jobs = app.bg_jobs.read().unwrap(); - if let Some(job) = jobs.get(&job_id) { - if job.status != "running" { - break; - } - } else { - return Err(ThopError::Other(format!("Job {} not found", job_id))); - } - } - println!("Waiting for job {}...", job_id); - thread::sleep(Duration::from_millis(500)); - } - - // Display output - let job = { - let mut jobs = app.bg_jobs.write().unwrap(); - jobs.remove(&job_id) - }; - - if let Some(job) = job { - println!("Job {} ({}):", job_id, job.status); - if !job.stdout.is_empty() { - print!("{}", job.stdout); - if !job.stdout.ends_with('\n') { - println!(); - } - } - if !job.stderr.is_empty() { - eprint!("{}", job.stderr); - if !job.stderr.ends_with('\n') { - eprintln!(); - } - } - } - - Ok(()) -} - -/// Handle /kill command - kill a running background job -fn cmd_kill_job(app: &mut App, job_id_str: &str) -> Result<()> { - let job_id: usize = job_id_str.parse() - .map_err(|_| ThopError::Other(format!("Invalid job ID: {}", job_id_str)))?; - - let mut jobs = app.bg_jobs.write().unwrap(); - - let job = jobs.get_mut(&job_id) - .ok_or_else(|| ThopError::Other(format!("Job {} not found", job_id)))?; - - if job.status != "running" { - return Err(ThopError::Other(format!("Job {} is not running (status: {})", job_id, job.status))); - } - - // Mark as failed/killed - job.status = "failed".to_string(); - job.end_time = Some(std::time::Instant::now()); - job.stderr = "killed by user".to_string(); - job.exit_code = 137; // SIGKILL exit code - - // Remove from job list - jobs.remove(&job_id); - - println!("Job {} killed", job_id); - - Ok(()) -} - -/// Handle /copy command - copy files between sessions -fn cmd_copy(app: &mut App, src: &str, dst: &str) -> Result<()> { - // Parse source and destination (format: session:path or just path for active session) - let (src_session, src_path) = parse_file_spec(src); - let (dst_session, dst_path) = parse_file_spec(dst); - - // Default to active session if not specified - let active_session = app.sessions.get_active_session_name().to_string(); - let src_session = if src_session.is_empty() { active_session.clone() } else { src_session }; - let dst_session = if dst_session.is_empty() { active_session.clone() } else { dst_session }; - - // Handle "remote" as alias for active SSH session - let src_session = if src_session == "remote" { - if active_session == "local" { - return Err(ThopError::Other("no remote session active - use session name instead".to_string())); - } - active_session.clone() - } else { - src_session - }; - - let dst_session = if dst_session == "remote" { - if active_session == "local" { - return Err(ThopError::Other("no remote session active - use session name instead".to_string())); - } - active_session.clone() - } else { - dst_session - }; - - // Validate sessions exist - if !app.sessions.has_session(&src_session) { - return Err(ThopError::Other(format!("source session '{}' not found", src_session))); - } - if !app.sessions.has_session(&dst_session) { - return Err(ThopError::Other(format!("destination session '{}' not found", dst_session))); - } - - let src_type = app.sessions.get_session(&src_session).map(|s| s.session_type().to_string()).unwrap_or_default(); - let dst_type = app.sessions.get_session(&dst_session).map(|s| s.session_type().to_string()).unwrap_or_default(); - - // Handle different transfer scenarios - if src_type == "local" && dst_type == "local" { - return Err(ThopError::Other("both source and destination are local - use regular cp command".to_string())); - } - - if src_type == "local" && dst_type == "ssh" { - // Upload: local -> remote (via cat + execute) - println!("Uploading {} to {}:{}...", src_path, dst_session, dst_path); - let expanded_src = expand_path(&src_path); - let content = fs::read(&expanded_src) - .map_err(|e| ThopError::Other(format!("failed to read source file: {}", e)))?; - - // Use cat with heredoc to write file - let cmd = format!( - "cat > {} << 'THOP_EOF'\n{}\nTHOP_EOF", - shell_escape(&dst_path), - String::from_utf8_lossy(&content) - ); - let result = app.sessions.execute_on(&dst_session, &cmd)?; - if result.exit_code != 0 { - return Err(ThopError::Other(format!("failed to write file: {}", result.stderr.trim()))); - } - println!("Upload complete ({} bytes)", content.len()); - return Ok(()); - } - - if src_type == "ssh" && dst_type == "local" { - // Download: remote -> local (via cat) - println!("Downloading {}:{} to {}...", src_session, src_path, dst_path); - let cmd = format!("cat {}", shell_escape(&src_path)); - let result = app.sessions.execute_on(&src_session, &cmd)?; - if result.exit_code != 0 { - return Err(ThopError::Other(format!("failed to read file: {}", result.stderr.trim()))); - } - - let expanded_dst = expand_path(&dst_path); - fs::write(&expanded_dst, result.stdout.as_bytes()) - .map_err(|e| ThopError::Other(format!("failed to write file: {}", e)))?; - println!("Download complete ({} bytes)", result.stdout.len()); - return Ok(()); - } - - if src_type == "ssh" && dst_type == "ssh" { - // Remote to remote: download then upload - println!("Reading {}:{}...", src_session, src_path); - let cmd = format!("cat {}", shell_escape(&src_path)); - let result = app.sessions.execute_on(&src_session, &cmd)?; - if result.exit_code != 0 { - return Err(ThopError::Other(format!("failed to read from {}: {}", src_session, result.stderr.trim()))); - } - - println!("Writing to {}:{}...", dst_session, dst_path); - let write_cmd = format!( - "cat > {} << 'THOP_EOF'\n{}\nTHOP_EOF", - shell_escape(&dst_path), - result.stdout - ); - let write_result = app.sessions.execute_on(&dst_session, &write_cmd)?; - if write_result.exit_code != 0 { - return Err(ThopError::Other(format!("failed to write to {}: {}", dst_session, write_result.stderr.trim()))); - } - println!("Copy complete ({} bytes)", result.stdout.len()); - return Ok(()); - } - - Err(ThopError::Other("unsupported copy operation".to_string())) -} - -/// Handle /shell command - run interactive command -fn cmd_shell(app: &mut App, command: &str) -> Result<()> { - use std::process::{Command, Stdio}; - - let session_name = app.sessions.get_active_session_name(); - let session = app.sessions.get_session(session_name) - .ok_or_else(|| ThopError::Other("No active session".to_string()))?; - - if session.session_type() == "local" { - // For local sessions, spawn the command with inherited stdio - // This allows interactive programs like vim, top, etc. to work - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - - let status = Command::new(&shell) - .arg("-c") - .arg(command) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .map_err(|e| ThopError::Other(format!("Failed to execute command: {}", e)))?; - - if !status.success() { - if let Some(code) = status.code() { - println!("Command exited with code {}", code); - } - } - - Ok(()) - } else { - // For SSH sessions, we need PTY support which is more complex - // For now, provide a helpful message - Err(ThopError::Other( - "Interactive shell commands on SSH sessions require PTY support.\n\ - This feature is not yet fully implemented for remote sessions.\n\ - Tip: For simple commands, use regular execution instead of /shell.".to_string() - )) - } -} - -/// Parse a file specification in the format "session:path" or just "path" -fn parse_file_spec(spec: &str) -> (String, String) { - // Handle Windows-style paths (C:\...) by checking if it looks like a drive letter - if spec.len() >= 2 && spec.chars().nth(1) == Some(':') { - let first = spec.chars().next().unwrap(); - if first.is_ascii_alphabetic() { - return (String::new(), spec.to_string()); - } - } - - // Look for session:path format - if let Some(idx) = spec.find(':') { - if idx > 0 { - return (spec[..idx].to_string(), spec[idx + 1..].to_string()); - } - } - - // Just a path, no session specified - (String::new(), spec.to_string()) -} - -/// Expand ~ to home directory in path -fn expand_path(path: &str) -> PathBuf { - if path.starts_with("~/") { - dirs::home_dir() - .map(|h| h.join(&path[2..])) - .unwrap_or_else(|| PathBuf::from(path)) - } else { - PathBuf::from(path) - } -} - -/// Escape a string for shell use -fn shell_escape(s: &str) -> String { - if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\' || c == '$') { - format!("'{}'", s.replace('\'', "'\\''")) - } else { - s.to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_expand_path() { - let expanded = expand_path("~/test.txt"); - assert!(expanded.to_string_lossy().contains("test.txt")); - assert!(!expanded.to_string_lossy().starts_with("~/")); - - let regular = expand_path("/tmp/test.txt"); - assert_eq!(regular.to_string_lossy(), "/tmp/test.txt"); - } - - #[test] - fn test_shell_escape() { - assert_eq!(shell_escape("simple"), "simple"); - assert_eq!(shell_escape("with space"), "'with space'"); - assert_eq!(shell_escape("with'quote"), "'with'\\''quote'"); - } -} diff --git a/thop-rust/src/cli/mod.rs b/thop-rust/src/cli/mod.rs deleted file mode 100644 index 5397da4..0000000 --- a/thop-rust/src/cli/mod.rs +++ /dev/null @@ -1,517 +0,0 @@ -mod interactive; -mod proxy; - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, RwLock}; -use std::time::Instant; - -use clap::Parser; -use serde_json; - -use crate::config::Config; -use crate::error::{Result, ThopError}; -use crate::logger::{self, LogLevel, Logger}; -use crate::session::Manager as SessionManager; -use crate::state::Manager as StateManager; - -pub use interactive::run_interactive; -pub use proxy::run_proxy; - -/// Background job state -#[derive(Debug, Clone)] -pub struct BackgroundJob { - pub id: usize, - pub command: String, - pub session: String, - pub start_time: Instant, - pub end_time: Option, - pub status: String, // "running", "completed", "failed" - pub exit_code: i32, - pub stdout: String, - pub stderr: String, -} - -impl BackgroundJob { - pub fn new(id: usize, command: String, session: String) -> Self { - Self { - id, - command, - session, - start_time: Instant::now(), - end_time: None, - status: "running".to_string(), - exit_code: 0, - stdout: String::new(), - stderr: String::new(), - } - } -} - -/// thop - Terminal Hopper for Agents -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -pub struct Args { - /// Run in proxy mode (for AI agents) - #[arg(long)] - pub proxy: bool, - - /// Run as MCP (Model Context Protocol) server - #[arg(long)] - pub mcp: bool, - - /// Block dangerous/destructive commands (for AI agents) - #[arg(long)] - pub restricted: bool, - - /// Execute command and exit - #[arg(short = 'c', value_name = "COMMAND")] - pub command: Option, - - /// Show status and exit - #[arg(long)] - pub status: bool, - - /// Path to config file - #[arg(long, short = 'C')] - pub config: Option, - - /// Output in JSON format - #[arg(long)] - pub json: bool, - - /// Generate shell completions - #[arg(long, value_name = "SHELL")] - pub completions: Option, - - /// Verbose output - #[arg(long, short)] - pub verbose: bool, - - /// Quiet output - #[arg(long, short)] - pub quiet: bool, -} - -/// Main application -pub struct App { - pub version: String, - pub args: Args, - pub config: Config, - pub state: StateManager, - pub sessions: SessionManager, - /// Background jobs - pub bg_jobs: Arc>>, - /// Next job ID - pub next_job_id: Arc>, -} - -impl App { - /// Create a new App instance - pub fn new(version: impl Into) -> Result { - let args = Args::parse(); - - // Load configuration - let config = Config::load(args.config.as_deref())?; - - // Initialize logger - let log_level = if args.quiet { - LogLevel::Off - } else if args.verbose { - LogLevel::Debug - } else { - LogLevel::from_str(&config.settings.log_level) - }; - - // Only enable file logging in verbose mode - let log_file = if args.verbose { - Some(Logger::default_log_path()) - } else { - None - }; - - Logger::init(log_level, log_file); - logger::debug("Logger initialized"); - - // Initialize state manager - let state = StateManager::new(&config.settings.state_file); - if let Err(e) = state.load() { - logger::warn(&format!("Failed to load state: {}", e)); - } - - // Initialize session manager - let sessions = SessionManager::new(&config, Some(StateManager::new(&config.settings.state_file))); - - // Enable restricted mode if requested - if args.restricted { - sessions.set_restricted_mode(true); - logger::info("Restricted mode enabled - dangerous commands will be blocked"); - } - - logger::debug(&format!("Loaded {} sessions", sessions.session_names().len())); - - Ok(Self { - version: version.into(), - args, - config, - state, - sessions, - bg_jobs: Arc::new(RwLock::new(HashMap::new())), - next_job_id: Arc::new(Mutex::new(1)), - }) - } - - /// Run the application - pub fn run(&mut self) -> Result<()> { - // Handle special flags - if self.args.status { - return self.print_status(); - } - - // Handle shell completions - if let Some(ref shell) = self.args.completions { - return self.print_completions(shell); - } - - // Handle single command execution - if let Some(ref cmd) = self.args.command.clone() { - return self.execute_command(cmd); - } - - // Run in appropriate mode - if self.args.mcp { - self.run_mcp() - } else if self.args.proxy { - run_proxy(self) - } else { - run_interactive(self) - } - } - - /// Run as MCP server - fn run_mcp(&mut self) -> Result<()> { - use crate::mcp::Server as McpServer; - use crate::state::Manager as StateManager; - - // Create a fresh config, state, and session manager for MCP - let config = self.config.clone(); - let state = StateManager::new(&config.settings.state_file); - let sessions = crate::session::Manager::new(&config, Some(StateManager::new(&config.settings.state_file))); - - let mut mcp_server = McpServer::new(config, sessions, state); - mcp_server.run() - } - - /// Execute a single command and exit - fn execute_command(&mut self, cmd: &str) -> Result<()> { - let result = self.sessions.execute(cmd)?; - - if !result.stdout.is_empty() { - print!("{}", result.stdout); - } - if !result.stderr.is_empty() { - eprint!("{}", result.stderr); - } - - if result.exit_code != 0 { - std::process::exit(result.exit_code); - } - - Ok(()) - } - - /// Print shell completions - fn print_completions(&self, shell: &str) -> Result<()> { - match shell.to_lowercase().as_str() { - "bash" => { - println!("{}", generate_bash_completion()); - } - "zsh" => { - println!("{}", generate_zsh_completion()); - } - "fish" => { - println!("{}", generate_fish_completion()); - } - _ => { - return Err(ThopError::Other(format!( - "Unsupported shell: {}. Supported: bash, zsh, fish", - shell - ))); - } - } - Ok(()) - } - - /// Print status of all sessions - pub fn print_status(&self) -> Result<()> { - let sessions = self.sessions.list_sessions(); - - if self.args.json { - let json = serde_json::to_string_pretty(&sessions) - .map_err(|e| ThopError::Other(format!("Failed to serialize: {}", e)))?; - println!("{}", json); - } else { - println!("Sessions:"); - for s in sessions { - let status = if s.connected { "connected" } else { "disconnected" }; - let active = if s.active { " [active]" } else { "" }; - - if s.session_type == "ssh" { - let host = s.host.as_deref().unwrap_or("unknown"); - let user = s.user.as_deref().unwrap_or("unknown"); - println!(" {:12} {}@{} ({}){} {}", s.name, user, host, status, active, s.cwd); - } else { - println!(" {:12} local ({}){} {}", s.name, status, active, s.cwd); - } - } - } - - Ok(()) - } - - /// Output an error in the appropriate format - pub fn output_error(&self, err: &ThopError) { - if self.args.json { - match err { - ThopError::Session(session_err) => { - if let Ok(json) = serde_json::to_string(session_err) { - eprintln!("{}", json); - } - } - _ => { - let json = serde_json::json!({ - "error": true, - "message": err.to_string() - }); - eprintln!("{}", json); - } - } - } else { - match err { - ThopError::Session(session_err) => { - eprintln!("Error: {}", session_err.message); - if let Some(ref suggestion) = session_err.suggestion { - eprintln!("Suggestion: {}", suggestion); - } - } - _ => { - eprintln!("Error: {}", err); - } - } - } - } -} - -/// Print help for slash commands -pub fn print_slash_help() { - println!( - r#"Available commands: - /connect Connect to an SSH session - /switch Switch to a session - /local Switch to local shell (alias for /switch local) - /status Show all sessions - /close Close an SSH connection - /auth Set password for SSH session - /trust Trust host key for SSH session - /copy Copy file between sessions (session:path format) - /add-session [user] Add new SSH session - /read Read file contents from current session - /write Write content to file - /env [KEY=VALUE] Show or set environment variables - /shell Run interactive command (vim, top, etc.) - /bg Run command in background - /jobs List background jobs - /fg Wait for job and show output - /kill Kill a running background job - /help Show this help - /exit Exit thop - -Shortcuts: - /c = /connect - /sw = /switch - /l = /local - /s = /status - /d = /close (disconnect) - /cp = /copy - /cat = /read - /sh = /shell - /add = /add-session - /q = /exit - -Copy examples: - /copy local:/path/file remote:/path/file Upload to active SSH session - /copy remote:/path/file local:/path/file Download from active SSH session - /copy server1:/path/file server2:/path/file Copy between two SSH sessions - -Interactive commands: - /shell vim file.txt Edit file with vim - /shell top Run interactive top - /sh bash Start interactive bash shell - -Background jobs: - /bg sleep 60 Run 'sleep 60' in background - /jobs List all background jobs - /fg 1 Wait for job 1 and show output - /kill 1 Kill running job 1"# - ); -} - -/// Print CLI help -pub fn print_help() { - println!( - r#"thop - Terminal Hopper for Agents - -USAGE: - thop [OPTIONS] Start interactive mode - thop --proxy Start proxy mode (for AI agents) - thop --mcp Start MCP server mode (for AI agents) - thop -c "command" Execute command and exit - thop --status Show status and exit - -OPTIONS: - --proxy Run in proxy mode (SHELL compatible) - --mcp Run as MCP (Model Context Protocol) server - --restricted Block dangerous/destructive commands (for AI agents) - -c Execute command and exit with its exit code - --status Show all sessions and exit - -C, --config Use alternate config file - --json Output in JSON format - --completions Generate shell completions (bash, zsh, fish) - -v, --verbose Increase logging verbosity - -q, --quiet Suppress non-error output - -h, --help Print help information - -V, --version Print version - -RESTRICTED MODE: - When --restricted is enabled, the following command categories are blocked: - - Privilege Escalation: - sudo, su, doas, pkexec - - Destructive File Operations: - rm, rmdir, shred, wipe, srm, unlink, dd, truncate (to 0) - - System Modifications: - chmod, chown, chgrp, chattr, mkfs, fdisk, parted, mount, umount, - shutdown, reboot, poweroff, halt, useradd, userdel, usermod, - groupadd, groupdel, passwd, systemctl, service, insmod, rmmod, - modprobe, setenforce, aa-enforce, aa-complain - -INTERACTIVE MODE COMMANDS: - /connect Establish SSH connection - /switch Change active context - /local Switch to local shell - /status Show all sessions - /close Close SSH connection - /env [KEY=VALUE] Show or set environment variables - /help Show commands - -EXAMPLES: - # Start interactive mode - thop - - # Execute single command - thop -c "ls -la" - - # Use as shell for AI agent with safety restrictions - SHELL="thop --proxy --restricted" claude - - # Run as MCP server - thop --mcp - - # Check status - thop --status"# - ); -} - -/// Generate bash completion script -fn generate_bash_completion() -> &'static str { - r#"# Bash completion for thop - -_thop() { - local cur prev opts - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - - # Main options - opts="--proxy --mcp --restricted --status --config --json -v --verbose -q --quiet -h --help -V --version -c --completions" - - # Handle specific options - case "${prev}" in - --config|-C) - COMPREPLY=( $(compgen -f -- "${cur}") ) - return 0 - ;; - -c) - # No completion for command argument - return 0 - ;; - --completions) - COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") ) - return 0 - ;; - esac - - # Complete options - if [[ ${cur} == -* ]]; then - COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) - return 0 - fi -} - -complete -F _thop thop"# -} - -/// Generate zsh completion script -fn generate_zsh_completion() -> &'static str { - r#"#compdef thop - -# Zsh completion for thop - -_thop() { - local -a opts - - opts=( - '--proxy[Run in proxy mode for AI agents]' - '--mcp[Run as MCP (Model Context Protocol) server]' - '--restricted[Block dangerous/destructive commands for AI agents]' - '-c[Execute command and exit]:command:' - '--status[Show status and exit]' - '-C[Use alternate config file]:config file:_files' - '--config[Use alternate config file]:config file:_files' - '--json[Output in JSON format]' - '--completions[Generate shell completions]:shell:(bash zsh fish)' - '-v[Verbose output]' - '--verbose[Verbose output]' - '-q[Quiet output]' - '--quiet[Quiet output]' - '-h[Show help]' - '--help[Show help]' - '-V[Show version]' - '--version[Show version]' - ) - - _arguments -s $opts -} - -_thop "$@""# -} - -/// Generate fish completion script -fn generate_fish_completion() -> &'static str { - r#"# Fish completion for thop - -# Main options -complete -c thop -l proxy -d 'Run in proxy mode for AI agents' -complete -c thop -l mcp -d 'Run as MCP (Model Context Protocol) server' -complete -c thop -l restricted -d 'Block dangerous/destructive commands for AI agents' -complete -c thop -s c -r -d 'Execute command and exit' -complete -c thop -l status -d 'Show status and exit' -complete -c thop -s C -l config -r -F -d 'Use alternate config file' -complete -c thop -l json -d 'Output in JSON format' -complete -c thop -l completions -r -a 'bash zsh fish' -d 'Generate shell completions' -complete -c thop -s v -l verbose -d 'Verbose output' -complete -c thop -s q -l quiet -d 'Quiet output' -complete -c thop -s h -l help -d 'Show help' -complete -c thop -s V -l version -d 'Show version'"# -} diff --git a/thop-rust/src/cli/proxy.rs b/thop-rust/src/cli/proxy.rs deleted file mode 100644 index da07447..0000000 --- a/thop-rust/src/cli/proxy.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::io::{self, BufRead, Write}; - -use crate::error::{Result, SessionError, ThopError}; -use super::App; - -/// Run proxy mode for AI agent integration -pub fn run_proxy(app: &mut App) -> Result<()> { - let stdin = io::stdin(); - let handle = stdin.lock(); - - for line in handle.lines() { - let input = match line { - Ok(input) => input, - Err(_) => break, // EOF or error - }; - - // Strip CR if present (Windows line endings) - let input = input.trim_end_matches('\r'); - - if input.is_empty() { - continue; - } - - // Check for slash commands - if input.starts_with('/') { - if let Err(e) = handle_proxy_slash_command(app, input) { - app.output_error(&e); - } - continue; - } - - // Execute command on active session - match app.sessions.execute(input) { - Ok(result) => { - // Output results - if !result.stdout.is_empty() { - print!("{}", result.stdout); - if !result.stdout.ends_with('\n') { - println!(); - } - } - - if !result.stderr.is_empty() { - eprint!("{}", result.stderr); - if !result.stderr.ends_with('\n') { - eprintln!(); - } - } - - // Flush output - io::stdout().flush().ok(); - io::stderr().flush().ok(); - - // In verbose mode, show exit code for non-zero exits - if result.exit_code != 0 && app.args.verbose { - eprintln!("[exit code: {}]", result.exit_code); - } - } - Err(e) => { - app.output_error(&e); - // In proxy mode, continue even on error - } - } - } - - Ok(()) -} - -/// Handle slash commands in proxy mode -fn handle_proxy_slash_command(app: &mut App, input: &str) -> Result<()> { - let parts: Vec<&str> = input.split_whitespace().collect(); - if parts.is_empty() { - return Ok(()); - } - - let cmd = parts[0].to_lowercase(); - let args = &parts[1..]; - - match cmd.as_str() { - "/status" | "/s" => { - app.print_status() - } - - "/connect" | "/c" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /connect ".to_string())); - } - let name = args[0]; - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - println!("Connecting to {}...", name); - app.sessions.connect(name)?; - println!("Connected to {}", name); - Ok(()) - } - - "/switch" | "/sw" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /switch ".to_string())); - } - let name = args[0]; - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - - // For SSH sessions, connect if not connected - let session = app.sessions.get_session(name).unwrap(); - if session.session_type() == "ssh" && !session.is_connected() { - println!("Connecting to {}...", name); - app.sessions.connect(name)?; - println!("Connected to {}", name); - } - - app.sessions.set_active_session(name)?; - println!("Switched to {}", name); - Ok(()) - } - - "/local" | "/l" => { - app.sessions.set_active_session("local")?; - println!("Switched to local"); - Ok(()) - } - - "/close" | "/disconnect" | "/d" => { - if args.is_empty() { - return Err(ThopError::Other("usage: /close ".to_string())); - } - let name = args[0]; - if !app.sessions.has_session(name) { - return Err(SessionError::session_not_found(name).into()); - } - - let session = app.sessions.get_session(name).unwrap(); - if session.session_type() == "local" { - println!("Cannot close local session"); - return Ok(()); - } - - if !session.is_connected() { - println!("Session '{}' is not connected", name); - return Ok(()); - } - - app.sessions.disconnect(name)?; - println!("Disconnected from {}", name); - - // Switch to local if we closed the active session - if app.sessions.get_active_session_name() == name { - app.sessions.set_active_session("local")?; - println!("Switched to local"); - } - Ok(()) - } - - _ => { - Err(ThopError::Other(format!( - "unknown command: {} (supported: /connect, /switch, /local, /status, /close)", - cmd - ))) - } - } -} - -#[cfg(test)] -mod tests { - // Proxy mode tests would typically be integration tests - // due to stdin/stdout interaction -} diff --git a/thop-rust/src/config/mod.rs b/thop-rust/src/config/mod.rs deleted file mode 100644 index deb6762..0000000 --- a/thop-rust/src/config/mod.rs +++ /dev/null @@ -1,279 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::env; -use std::fs; -use std::path::PathBuf; - -use crate::error::{Result, ThopError}; - -/// Main configuration structure -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - #[serde(default)] - pub settings: Settings, - #[serde(default)] - pub sessions: HashMap, -} - -/// Global settings -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Settings { - #[serde(default = "default_session")] - pub default_session: String, - #[serde(default = "default_command_timeout")] - pub command_timeout: u32, - #[serde(default = "default_reconnect_attempts")] - pub reconnect_attempts: u32, - #[serde(default = "default_reconnect_backoff")] - pub reconnect_backoff_base: u32, - #[serde(default = "default_log_level")] - pub log_level: String, - #[serde(default = "default_state_file")] - pub state_file: String, -} - -/// Session configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Session { - #[serde(rename = "type")] - pub session_type: String, - #[serde(default)] - pub shell: Option, - #[serde(default)] - pub host: Option, - #[serde(default)] - pub user: Option, - #[serde(default)] - pub port: Option, - #[serde(default)] - pub identity_file: Option, - #[serde(default)] - pub jump_host: Option, - #[serde(default)] - pub startup_commands: Vec, -} - -fn default_session() -> String { - "local".to_string() -} - -fn default_command_timeout() -> u32 { - 300 -} - -fn default_reconnect_attempts() -> u32 { - 5 -} - -fn default_reconnect_backoff() -> u32 { - 2 -} - -fn default_log_level() -> String { - "info".to_string() -} - -fn default_state_file() -> String { - if let Some(val) = env::var_os("THOP_STATE_FILE") { - return val.to_string_lossy().to_string(); - } - - let data_dir = env::var("XDG_DATA_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".local/share") - }); - - data_dir - .join("thop/state.json") - .to_string_lossy() - .to_string() -} - -fn default_shell() -> String { - env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) -} - -impl Default for Settings { - fn default() -> Self { - Self { - default_session: default_session(), - command_timeout: default_command_timeout(), - reconnect_attempts: default_reconnect_attempts(), - reconnect_backoff_base: default_reconnect_backoff(), - log_level: default_log_level(), - state_file: default_state_file(), - } - } -} - -impl Default for Config { - fn default() -> Self { - let mut sessions = HashMap::new(); - sessions.insert( - "local".to_string(), - Session { - session_type: "local".to_string(), - shell: Some(default_shell()), - host: None, - user: None, - port: None, - identity_file: None, - jump_host: None, - startup_commands: vec![], - }, - ); - - Self { - settings: Settings::default(), - sessions, - } - } -} - -impl Config { - /// Load configuration from file or return defaults - pub fn load(path: Option<&str>) -> Result { - let path = path.map(PathBuf::from).unwrap_or_else(default_config_path); - - let mut config = if path.exists() { - let content = fs::read_to_string(&path).map_err(|e| { - ThopError::Config(format!("Failed to read config file: {}", e)) - })?; - toml::from_str(&content) - .map_err(|e| ThopError::Config(format!("Failed to parse config file: {}", e)))? - } else { - Config::default() - }; - - // Ensure local session exists - if !config.sessions.contains_key("local") { - config.sessions.insert( - "local".to_string(), - Session { - session_type: "local".to_string(), - shell: Some(default_shell()), - host: None, - user: None, - port: None, - identity_file: None, - jump_host: None, - startup_commands: vec![], - }, - ); - } - - // Apply environment overrides - config.apply_env_overrides(); - - Ok(config) - } - - /// Apply environment variable overrides - fn apply_env_overrides(&mut self) { - if let Ok(val) = env::var("THOP_STATE_FILE") { - self.settings.state_file = val; - } - if let Ok(val) = env::var("THOP_LOG_LEVEL") { - self.settings.log_level = val; - } - if let Ok(val) = env::var("THOP_DEFAULT_SESSION") { - self.settings.default_session = val; - } - } - - /// Get a session by name - pub fn get_session(&self, name: &str) -> Option<&Session> { - self.sessions.get(name) - } - - /// Get all session names - pub fn session_names(&self) -> Vec<&str> { - self.sessions.keys().map(|s| s.as_str()).collect() - } -} - -/// Get the default config file path -pub fn default_config_path() -> PathBuf { - if let Ok(path) = env::var("THOP_CONFIG") { - return PathBuf::from(path); - } - - let config_dir = env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".config") - }); - - config_dir.join("thop/config.toml") -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::TempDir; - - #[test] - fn test_default_config() { - let config = Config::default(); - assert_eq!(config.settings.default_session, "local"); - assert_eq!(config.settings.command_timeout, 300); - assert!(config.sessions.contains_key("local")); - } - - #[test] - fn test_load_nonexistent_config() { - let config = Config::load(Some("/nonexistent/path/config.toml")).unwrap(); - assert_eq!(config.settings.default_session, "local"); - } - - #[test] - fn test_load_valid_config() { - let tmp_dir = TempDir::new().unwrap(); - let config_path = tmp_dir.path().join("config.toml"); - - let content = r#" -[settings] -default_session = "prod" -command_timeout = 600 -log_level = "debug" - -[sessions.local] -type = "local" -shell = "/bin/zsh" - -[sessions.prod] -type = "ssh" -host = "prod.example.com" -user = "deploy" -port = 2222 -"#; - - let mut file = fs::File::create(&config_path).unwrap(); - file.write_all(content.as_bytes()).unwrap(); - - let config = Config::load(Some(config_path.to_str().unwrap())).unwrap(); - - assert_eq!(config.settings.default_session, "prod"); - assert_eq!(config.settings.command_timeout, 600); - assert_eq!(config.settings.log_level, "debug"); - - let prod = config.sessions.get("prod").unwrap(); - assert_eq!(prod.session_type, "ssh"); - assert_eq!(prod.host.as_ref().unwrap(), "prod.example.com"); - assert_eq!(prod.user.as_ref().unwrap(), "deploy"); - assert_eq!(prod.port.unwrap(), 2222); - } - - #[test] - fn test_get_session() { - let config = Config::default(); - assert!(config.get_session("local").is_some()); - assert!(config.get_session("nonexistent").is_none()); - } -} diff --git a/thop-rust/src/error.rs b/thop-rust/src/error.rs deleted file mode 100644 index 39e642a..0000000 --- a/thop-rust/src/error.rs +++ /dev/null @@ -1,163 +0,0 @@ -use serde::Serialize; -use thiserror::Error; - -/// Error codes for structured error handling -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum ErrorCode { - #[serde(rename = "CONNECTION_FAILED")] - ConnectionFailed, - #[serde(rename = "CONNECTION_TIMEOUT")] - ConnectionTimeout, - #[serde(rename = "AUTH_PASSWORD_REQUIRED")] - AuthPasswordRequired, - #[serde(rename = "AUTH_KEY_REJECTED")] - AuthKeyRejected, - #[serde(rename = "AUTH_FAILED")] - AuthFailed, - #[serde(rename = "HOST_KEY_VERIFICATION_FAILED")] - HostKeyVerificationFailed, - #[serde(rename = "HOST_KEY_CHANGED")] - HostKeyChanged, - #[serde(rename = "COMMAND_TIMEOUT")] - CommandTimeout, - #[serde(rename = "COMMAND_RESTRICTED")] - CommandRestricted, - #[serde(rename = "SESSION_NOT_FOUND")] - SessionNotFound, - #[serde(rename = "SESSION_DISCONNECTED")] - SessionDisconnected, -} - -/// Structured session error -#[derive(Debug, Error, Serialize)] -#[error("{message}")] -pub struct SessionError { - pub code: ErrorCode, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub session: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - pub retryable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub suggestion: Option, -} - -impl SessionError { - pub fn new(code: ErrorCode, message: impl Into, session: impl Into) -> Self { - Self { - code, - message: message.into(), - session: Some(session.into()), - host: None, - retryable: false, - suggestion: None, - } - } - - pub fn with_host(mut self, host: impl Into) -> Self { - self.host = Some(host.into()); - self - } - - pub fn with_retryable(mut self, retryable: bool) -> Self { - self.retryable = retryable; - self - } - - pub fn with_suggestion(mut self, suggestion: impl Into) -> Self { - self.suggestion = Some(suggestion.into()); - self - } - - pub fn session_not_found(name: &str) -> Self { - Self::new( - ErrorCode::SessionNotFound, - format!("Session '{}' not found", name), - name, - ) - } - - pub fn session_disconnected(name: &str) -> Self { - Self::new( - ErrorCode::SessionDisconnected, - format!("Session '{}' is not connected", name), - name, - ) - .with_suggestion("Use /connect to establish connection") - } - - pub fn connection_failed(session: &str, host: &str, err: impl std::fmt::Display) -> Self { - Self::new( - ErrorCode::ConnectionFailed, - format!("Failed to connect to {}: {}", host, err), - session, - ) - .with_host(host) - .with_retryable(true) - .with_suggestion("Check network connectivity and host address") - } - - pub fn connection_timeout(session: &str, host: &str) -> Self { - Self::new( - ErrorCode::ConnectionTimeout, - format!("Connection timed out to {}", host), - session, - ) - .with_host(host) - .with_retryable(true) - .with_suggestion("Check network connectivity and firewall settings") - } - - pub fn auth_failed(session: &str, host: &str) -> Self { - Self::new( - ErrorCode::AuthFailed, - format!("Authentication failed for {}", host), - session, - ) - .with_host(host) - .with_suggestion("Check SSH key or credentials") - } - - pub fn host_key_verification_failed(session: &str, host: &str) -> Self { - Self::new( - ErrorCode::HostKeyVerificationFailed, - format!("Host key verification failed for {}", host), - session, - ) - .with_host(host) - .with_suggestion("Add the host to known_hosts: ssh-keyscan >> ~/.ssh/known_hosts") - } - - pub fn command_restricted(command: &str, category: &str) -> Self { - Self { - code: ErrorCode::CommandRestricted, - message: format!("{}: '{}' is not allowed in restricted mode", category, command), - session: None, - host: None, - retryable: false, - suggestion: Some("Remove --restricted flag to allow this command, or use a different approach".to_string()), - } - } -} - -/// General application error -#[derive(Debug, Error)] -pub enum ThopError { - #[error("{0}")] - Session(#[from] SessionError), - - #[error("Configuration error: {0}")] - Config(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("State error: {0}")] - State(String), - - #[error("{0}")] - Other(String), -} - -pub type Result = std::result::Result; diff --git a/thop-rust/src/logger.rs b/thop-rust/src/logger.rs deleted file mode 100644 index d262d62..0000000 --- a/thop-rust/src/logger.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Simple logging module for thop - -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::PathBuf; -use std::sync::Mutex; - -/// Log levels -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum LogLevel { - Off, - Error, - Warn, - Info, - Debug, -} - -impl LogLevel { - pub fn from_str(s: &str) -> Self { - match s.to_lowercase().as_str() { - "off" | "none" => LogLevel::Off, - "error" => LogLevel::Error, - "warn" | "warning" => LogLevel::Warn, - "info" => LogLevel::Info, - "debug" => LogLevel::Debug, - _ => LogLevel::Info, - } - } -} - -/// Global logger state -static LOGGER: Mutex> = Mutex::new(None); - -/// Logger configuration and state -pub struct Logger { - level: LogLevel, - log_file: Option, -} - -impl Logger { - /// Initialize the global logger - pub fn init(level: LogLevel, log_file: Option) { - let mut logger = LOGGER.lock().unwrap(); - *logger = Some(Logger { level, log_file }); - } - - /// Get the default log file path - pub fn default_log_path() -> PathBuf { - dirs::data_dir() - .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))) - .join("thop") - .join("thop.log") - } - - /// Log a message at the specified level - fn log(&self, level: LogLevel, message: &str) { - if level > self.level { - return; - } - - let level_str = match level { - LogLevel::Off => return, - LogLevel::Error => "ERROR", - LogLevel::Warn => "WARN", - LogLevel::Info => "INFO", - LogLevel::Debug => "DEBUG", - }; - - let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); - let formatted = format!("[{}] {} - {}\n", timestamp, level_str, message); - - // Write to log file if configured - if let Some(ref path) = self.log_file { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).ok(); - } - - if let Ok(mut file) = OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - file.write_all(formatted.as_bytes()).ok(); - } - } - - // Also write to stderr for error level in debug mode - if level == LogLevel::Error || (level == LogLevel::Debug && self.level >= LogLevel::Debug) { - eprint!("{}", formatted); - } - } -} - -/// Log an error message -pub fn error(message: &str) { - if let Ok(guard) = LOGGER.lock() { - if let Some(ref logger) = *guard { - logger.log(LogLevel::Error, message); - } - } -} - -/// Log a warning message -pub fn warn(message: &str) { - if let Ok(guard) = LOGGER.lock() { - if let Some(ref logger) = *guard { - logger.log(LogLevel::Warn, message); - } - } -} - -/// Log an info message -pub fn info(message: &str) { - if let Ok(guard) = LOGGER.lock() { - if let Some(ref logger) = *guard { - logger.log(LogLevel::Info, message); - } - } -} - -/// Log a debug message -pub fn debug(message: &str) { - if let Ok(guard) = LOGGER.lock() { - if let Some(ref logger) = *guard { - logger.log(LogLevel::Debug, message); - } - } -} - -/// Log a formatted error message -#[macro_export] -macro_rules! log_error { - ($($arg:tt)*) => { - $crate::logger::error(&format!($($arg)*)) - }; -} - -/// Log a formatted warning message -#[macro_export] -macro_rules! log_warn { - ($($arg:tt)*) => { - $crate::logger::warn(&format!($($arg)*)) - }; -} - -/// Log a formatted info message -#[macro_export] -macro_rules! log_info { - ($($arg:tt)*) => { - $crate::logger::info(&format!($($arg)*)) - }; -} - -/// Log a formatted debug message -#[macro_export] -macro_rules! log_debug { - ($($arg:tt)*) => { - $crate::logger::debug(&format!($($arg)*)) - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_log_level_from_str() { - assert_eq!(LogLevel::from_str("debug"), LogLevel::Debug); - assert_eq!(LogLevel::from_str("DEBUG"), LogLevel::Debug); - assert_eq!(LogLevel::from_str("info"), LogLevel::Info); - assert_eq!(LogLevel::from_str("warn"), LogLevel::Warn); - assert_eq!(LogLevel::from_str("warning"), LogLevel::Warn); - assert_eq!(LogLevel::from_str("error"), LogLevel::Error); - assert_eq!(LogLevel::from_str("off"), LogLevel::Off); - assert_eq!(LogLevel::from_str("none"), LogLevel::Off); - assert_eq!(LogLevel::from_str("unknown"), LogLevel::Info); - } - - #[test] - fn test_log_level_ordering() { - assert!(LogLevel::Debug > LogLevel::Info); - assert!(LogLevel::Info > LogLevel::Warn); - assert!(LogLevel::Warn > LogLevel::Error); - assert!(LogLevel::Error > LogLevel::Off); - } -} diff --git a/thop-rust/src/main.rs b/thop-rust/src/main.rs deleted file mode 100644 index 3b89e34..0000000 --- a/thop-rust/src/main.rs +++ /dev/null @@ -1,32 +0,0 @@ -mod cli; -mod config; -mod error; -mod logger; -mod mcp; -mod restriction; -mod session; -mod sshconfig; -mod state; - -use std::process::ExitCode; - -use cli::App; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -fn main() -> ExitCode { - match App::new(VERSION) { - Ok(mut app) => { - if let Err(e) = app.run() { - app.output_error(&e); - ExitCode::from(1) - } else { - ExitCode::SUCCESS - } - } - Err(e) => { - eprintln!("Error: {}", e); - ExitCode::from(1) - } - } -} diff --git a/thop-rust/src/mcp/errors.rs b/thop-rust/src/mcp/errors.rs deleted file mode 100644 index 9a043d3..0000000 --- a/thop-rust/src/mcp/errors.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! MCP error codes and types - -use serde::{Deserialize, Serialize}; - -use super::protocol::{Content, ToolCallResult}; - -/// Error codes for MCP responses -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum ErrorCode { - // Session errors - SessionNotFound, - SessionNotConnected, - SessionAlreadyExists, - NoActiveSession, - CannotCloseLocal, - - // Connection errors - ConnectionFailed, - AuthFailed, - AuthKeyFailed, - AuthPasswordFailed, - HostKeyUnknown, - HostKeyMismatch, - ConnectionTimeout, - ConnectionRefused, - - // Command execution errors - CommandFailed, - CommandTimeout, - CommandNotFound, - PermissionDenied, - - // Parameter errors - InvalidParameter, - MissingParameter, - - // Feature errors - NotImplemented, - OperationFailed, -} - -impl std::fmt::Display for ErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - ErrorCode::SessionNotFound => "SESSION_NOT_FOUND", - ErrorCode::SessionNotConnected => "SESSION_NOT_CONNECTED", - ErrorCode::SessionAlreadyExists => "SESSION_ALREADY_EXISTS", - ErrorCode::NoActiveSession => "NO_ACTIVE_SESSION", - ErrorCode::CannotCloseLocal => "CANNOT_CLOSE_LOCAL", - ErrorCode::ConnectionFailed => "CONNECTION_FAILED", - ErrorCode::AuthFailed => "AUTH_FAILED", - ErrorCode::AuthKeyFailed => "AUTH_KEY_FAILED", - ErrorCode::AuthPasswordFailed => "AUTH_PASSWORD_FAILED", - ErrorCode::HostKeyUnknown => "HOST_KEY_UNKNOWN", - ErrorCode::HostKeyMismatch => "HOST_KEY_MISMATCH", - ErrorCode::ConnectionTimeout => "CONNECTION_TIMEOUT", - ErrorCode::ConnectionRefused => "CONNECTION_REFUSED", - ErrorCode::CommandFailed => "COMMAND_FAILED", - ErrorCode::CommandTimeout => "COMMAND_TIMEOUT", - ErrorCode::CommandNotFound => "COMMAND_NOT_FOUND", - ErrorCode::PermissionDenied => "PERMISSION_DENIED", - ErrorCode::InvalidParameter => "INVALID_PARAMETER", - ErrorCode::MissingParameter => "MISSING_PARAMETER", - ErrorCode::NotImplemented => "NOT_IMPLEMENTED", - ErrorCode::OperationFailed => "OPERATION_FAILED", - }; - write!(f, "{}", s) - } -} - -/// Structured MCP error -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MCPError { - pub code: ErrorCode, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub session: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub suggestion: Option, -} - -impl std::fmt::Display for MCPError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(ref session) = self.session { - write!(f, "[{}] {} (session: {})", self.code, self.message, session) - } else { - write!(f, "[{}] {}", self.code, self.message) - } - } -} - -impl std::error::Error for MCPError {} - -impl MCPError { - /// Create a new MCP error - pub fn new(code: ErrorCode, message: impl Into) -> Self { - Self { - code, - message: message.into(), - session: None, - suggestion: None, - } - } - - /// Add session information to the error - pub fn with_session(mut self, session: impl Into) -> Self { - self.session = Some(session.into()); - self - } - - /// Add a suggestion to the error - pub fn with_suggestion(mut self, suggestion: impl Into) -> Self { - self.suggestion = Some(suggestion.into()); - self - } - - /// Convert error to a tool call result - pub fn to_tool_result(&self) -> ToolCallResult { - let mut text = self.message.clone(); - if let Some(ref suggestion) = self.suggestion { - text = format!("{}\n\nSuggestion: {}", text, suggestion); - } - if let Some(ref session) = self.session { - text = format!("{}\n\nSession: {}", text, session); - } - text = format!("[{}] {}", self.code, text); - - ToolCallResult { - content: vec![Content::text(text)], - is_error: true, - } - } - - // Common error constructors - - /// Session not found error - pub fn session_not_found(session_name: &str) -> Self { - Self::new( - ErrorCode::SessionNotFound, - format!("Session '{}' not found", session_name), - ) - .with_session(session_name) - .with_suggestion("Use /status to see available sessions or /add-session to create a new one") - } - - /// Session not connected error - pub fn session_not_connected(session_name: &str) -> Self { - Self::new( - ErrorCode::SessionNotConnected, - format!("Session '{}' is not connected", session_name), - ) - .with_session(session_name) - .with_suggestion("Use /connect to establish a connection") - } - - /// SSH key authentication failed error - pub fn auth_key_failed(session_name: &str) -> Self { - Self::new(ErrorCode::AuthKeyFailed, "SSH key authentication failed") - .with_session(session_name) - .with_suggestion("Use /auth to provide a password or check your SSH key configuration") - } - - /// Password authentication failed error - pub fn auth_password_failed(session_name: &str) -> Self { - Self::new( - ErrorCode::AuthPasswordFailed, - "Password authentication failed", - ) - .with_session(session_name) - .with_suggestion("Verify the password is correct") - } - - /// Host key unknown error - pub fn host_key_unknown(session_name: &str) -> Self { - Self::new(ErrorCode::HostKeyUnknown, "Host key is not in known_hosts") - .with_session(session_name) - .with_suggestion("Use /trust to accept the host key") - } - - /// Connection failed error - pub fn connection_failed(session_name: &str, reason: &str) -> Self { - Self::new(ErrorCode::ConnectionFailed, format!("Connection failed: {}", reason)) - .with_session(session_name) - .with_suggestion("Check network connectivity and session configuration") - } - - /// Command timeout error - pub fn command_timeout(session_name: &str, timeout: u64) -> Self { - Self::new( - ErrorCode::CommandTimeout, - format!("Command execution timed out after {} seconds", timeout), - ) - .with_session(session_name) - .with_suggestion("Increase timeout parameter or run command in background") - } - - /// Missing parameter error - pub fn missing_parameter(param: &str) -> Self { - Self::new( - ErrorCode::MissingParameter, - format!("Required parameter '{}' is missing", param), - ) - .with_suggestion(format!("Provide the '{}' parameter", param)) - } - - /// Not implemented error - pub fn not_implemented(feature: &str) -> Self { - Self::new( - ErrorCode::NotImplemented, - format!("{} is not yet implemented", feature), - ) - .with_suggestion("This feature is planned for a future release") - } - - /// No active session error - pub fn no_active_session() -> Self { - Self::new(ErrorCode::NoActiveSession, "No active session") - .with_suggestion("Use /connect to establish a session or specify a session name") - } - - /// Cannot close local session error - pub fn cannot_close_local(session_name: &str) -> Self { - Self::new(ErrorCode::CannotCloseLocal, "Cannot close the local session") - .with_session(session_name) - .with_suggestion("Use /switch to change to another session instead") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_code_display() { - assert_eq!(format!("{}", ErrorCode::SessionNotFound), "SESSION_NOT_FOUND"); - assert_eq!(format!("{}", ErrorCode::AuthKeyFailed), "AUTH_KEY_FAILED"); - } - - #[test] - fn test_mcp_error_creation() { - let err = MCPError::new(ErrorCode::SessionNotFound, "Test error"); - assert_eq!(err.code, ErrorCode::SessionNotFound); - assert_eq!(err.message, "Test error"); - assert!(err.session.is_none()); - assert!(err.suggestion.is_none()); - } - - #[test] - fn test_mcp_error_with_session() { - let err = MCPError::new(ErrorCode::SessionNotFound, "Test error") - .with_session("test-session"); - assert_eq!(err.session, Some("test-session".to_string())); - } - - #[test] - fn test_mcp_error_to_tool_result() { - let err = MCPError::session_not_found("test-session"); - let result = err.to_tool_result(); - - assert!(result.is_error); - assert_eq!(result.content.len(), 1); - assert!(result.content[0].text.as_ref().unwrap().contains("SESSION_NOT_FOUND")); - } - - #[test] - fn test_common_error_constructors() { - let err = MCPError::session_not_found("prod"); - assert_eq!(err.code, ErrorCode::SessionNotFound); - assert!(err.session.is_some()); - assert!(err.suggestion.is_some()); - - let err = MCPError::command_timeout("prod", 30); - assert_eq!(err.code, ErrorCode::CommandTimeout); - assert!(err.message.contains("30")); - } -} diff --git a/thop-rust/src/mcp/handlers.rs b/thop-rust/src/mcp/handlers.rs deleted file mode 100644 index 407a66e..0000000 --- a/thop-rust/src/mcp/handlers.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! MCP request handlers - -use serde_json::Value; - -use crate::logger; - -use super::errors::MCPError; -use super::protocol::{ - InitializeParams, InitializeResult, LoggingCapability, Resource, - ResourceContent, ResourceReadParams, ResourceReadResult, ResourcesCapability, - ServerCapabilities, ServerInfo, ToolCallParams, ToolsCapability, -}; -use super::server::{Server, MCP_VERSION}; -use super::tools; - -/// Handle initialize request -pub fn handle_initialize(_server: &mut Server, params: Option) -> Result, MCPError> { - let params_value = params.ok_or_else(|| MCPError::missing_parameter("params"))?; - - let init_params: InitializeParams = serde_json::from_value(params_value) - .map_err(|e| MCPError::new(super::errors::ErrorCode::InvalidParameter, format!("Invalid params: {}", e)))?; - - logger::info(&format!( - "MCP client connected: {} v{} (protocol {})", - init_params.client_info.name, - init_params.client_info.version, - init_params.protocol_version - )); - - let result = InitializeResult { - protocol_version: MCP_VERSION.to_string(), - capabilities: ServerCapabilities { - tools: Some(ToolsCapability { list_changed: false }), - resources: Some(ResourcesCapability { - subscribe: false, - list_changed: false, - }), - logging: Some(LoggingCapability {}), - prompts: None, - experimental: None, - }, - server_info: ServerInfo { - name: "thop-mcp".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - }; - - Ok(Some(serde_json::to_value(result).unwrap())) -} - -/// Handle initialized notification -pub fn handle_initialized(_server: &mut Server, _params: Option) -> Result, MCPError> { - logger::debug("MCP client initialized"); - Ok(None) -} - -/// Handle tools/list request -pub fn handle_tools_list(_server: &mut Server, _params: Option) -> Result, MCPError> { - let tools = tools::get_tool_definitions(); - - let result = serde_json::json!({ - "tools": tools - }); - - Ok(Some(result)) -} - -/// Handle tools/call request -pub fn handle_tool_call(server: &mut Server, params: Option) -> Result, MCPError> { - let params_value = params.ok_or_else(|| MCPError::missing_parameter("params"))?; - - let call_params: ToolCallParams = serde_json::from_value(params_value) - .map_err(|e| MCPError::new(super::errors::ErrorCode::InvalidParameter, format!("Invalid params: {}", e)))?; - - logger::debug(&format!("Tool call: {}", call_params.name)); - - // Route to appropriate tool handler - let result = match call_params.name.as_str() { - "connect" => tools::tool_connect(server, call_params.arguments), - "switch" => tools::tool_switch(server, call_params.arguments), - "close" => tools::tool_close(server, call_params.arguments), - "status" => tools::tool_status(server, call_params.arguments), - "execute" => tools::tool_execute(server, call_params.arguments), - _ => { - return Err(MCPError::new( - super::errors::ErrorCode::InvalidParameter, - format!("Unknown tool: {}", call_params.name), - )); - } - }; - - Ok(Some(serde_json::to_value(result).unwrap())) -} - -/// Handle resources/list request -pub fn handle_resources_list(_server: &mut Server, _params: Option) -> Result, MCPError> { - let resources = vec![ - Resource { - uri: "session://active".to_string(), - name: "Active Session".to_string(), - description: Some("Information about the currently active session".to_string()), - mime_type: Some("application/json".to_string()), - }, - Resource { - uri: "session://all".to_string(), - name: "All Sessions".to_string(), - description: Some("Information about all configured sessions".to_string()), - mime_type: Some("application/json".to_string()), - }, - Resource { - uri: "config://thop".to_string(), - name: "Thop Configuration".to_string(), - description: Some("Current thop configuration".to_string()), - mime_type: Some("application/json".to_string()), - }, - Resource { - uri: "state://thop".to_string(), - name: "Thop State".to_string(), - description: Some("Current thop state including session states".to_string()), - mime_type: Some("application/json".to_string()), - }, - ]; - - let result = serde_json::json!({ - "resources": resources - }); - - Ok(Some(result)) -} - -/// Handle resources/read request -pub fn handle_resource_read(server: &mut Server, params: Option) -> Result, MCPError> { - let params_value = params.ok_or_else(|| MCPError::missing_parameter("params"))?; - - let read_params: ResourceReadParams = serde_json::from_value(params_value) - .map_err(|e| MCPError::new(super::errors::ErrorCode::InvalidParameter, format!("Invalid params: {}", e)))?; - - let content = match read_params.uri.as_str() { - "session://active" => get_active_session_resource(server)?, - "session://all" => get_all_sessions_resource(server)?, - "config://thop" => get_config_resource(server)?, - "state://thop" => get_state_resource(server)?, - _ => { - return Err(MCPError::new( - super::errors::ErrorCode::InvalidParameter, - format!("Unknown resource URI: {}", read_params.uri), - )); - } - }; - - let result = ResourceReadResult { - contents: vec![ResourceContent { - uri: read_params.uri, - mime_type: Some("application/json".to_string()), - text: Some(content), - blob: None, - }], - }; - - Ok(Some(serde_json::to_value(result).unwrap())) -} - -/// Handle ping request -pub fn handle_ping(_server: &mut Server, _params: Option) -> Result, MCPError> { - Ok(Some(serde_json::json!({ - "pong": true - }))) -} - -/// Handle cancelled notification -pub fn handle_cancelled(_server: &mut Server, _params: Option) -> Result, MCPError> { - logger::debug("Received cancellation notification"); - Ok(None) -} - -/// Handle progress notification -pub fn handle_progress(_server: &mut Server, params: Option) -> Result, MCPError> { - if let Some(params) = params { - if let Ok(progress) = serde_json::from_value::(params) { - logger::debug(&format!( - "Progress update: token={} progress={}/{}", - progress.progress_token, - progress.progress, - progress.total.unwrap_or(0.0) - )); - } - } - Ok(None) -} - -// Resource helper functions - -fn get_active_session_resource(server: &Server) -> Result { - let session_name = server.sessions.get_active_session_name(); - let session = server.sessions.get_session(session_name) - .ok_or_else(|| MCPError::no_active_session())?; - - let info = serde_json::json!({ - "name": session_name, - "type": session.session_type(), - "connected": session.is_connected(), - "cwd": session.get_cwd(), - "environment": session.get_env() - }); - - serde_json::to_string_pretty(&info) - .map_err(|e| MCPError::new(super::errors::ErrorCode::OperationFailed, format!("Failed to serialize: {}", e))) -} - -fn get_all_sessions_resource(server: &Server) -> Result { - let sessions = server.sessions.list_sessions(); - serde_json::to_string_pretty(&sessions) - .map_err(|e| MCPError::new(super::errors::ErrorCode::OperationFailed, format!("Failed to serialize: {}", e))) -} - -fn get_config_resource(server: &Server) -> Result { - serde_json::to_string_pretty(&server.config) - .map_err(|e| MCPError::new(super::errors::ErrorCode::OperationFailed, format!("Failed to serialize: {}", e))) -} - -fn get_state_resource(server: &Server) -> Result { - let active_session = server.state.get_active_session(); - - let state_data = serde_json::json!({ - "active_session": active_session, - }); - - serde_json::to_string_pretty(&state_data) - .map_err(|e| MCPError::new(super::errors::ErrorCode::OperationFailed, format!("Failed to serialize: {}", e))) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::session::Manager as SessionManager; - use crate::state::Manager as StateManager; - - fn create_test_server() -> Server { - let config = Config::default(); - let state = StateManager::new(&config.settings.state_file); - let sessions = SessionManager::new(&config, Some(StateManager::new(&config.settings.state_file))); - Server::new(config, sessions, state) - } - - #[test] - fn test_handle_ping() { - let mut server = create_test_server(); - let result = handle_ping(&mut server, None).unwrap(); - assert!(result.is_some()); - let value = result.unwrap(); - assert_eq!(value["pong"], true); - } - - #[test] - fn test_handle_tools_list() { - let mut server = create_test_server(); - let result = handle_tools_list(&mut server, None).unwrap(); - assert!(result.is_some()); - let value = result.unwrap(); - assert!(value["tools"].is_array()); - } - - #[test] - fn test_handle_resources_list() { - let mut server = create_test_server(); - let result = handle_resources_list(&mut server, None).unwrap(); - assert!(result.is_some()); - let value = result.unwrap(); - assert!(value["resources"].is_array()); - } - - #[test] - fn test_handle_initialized() { - let mut server = create_test_server(); - let result = handle_initialized(&mut server, None).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_handle_cancelled() { - let mut server = create_test_server(); - let result = handle_cancelled(&mut server, None).unwrap(); - assert!(result.is_none()); - } -} diff --git a/thop-rust/src/mcp/mod.rs b/thop-rust/src/mcp/mod.rs deleted file mode 100644 index ebadee3..0000000 --- a/thop-rust/src/mcp/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! MCP (Model Context Protocol) server implementation for thop -//! -//! This module implements the MCP protocol to allow AI agents to interact -//! with thop sessions programmatically. - -mod errors; -mod handlers; -mod protocol; -mod server; -mod tools; - -// Re-exports for external use -#[allow(unused_imports)] -pub use errors::{ErrorCode, MCPError}; -pub use server::Server; diff --git a/thop-rust/src/mcp/protocol.rs b/thop-rust/src/mcp/protocol.rs deleted file mode 100644 index 0cc8385..0000000 --- a/thop-rust/src/mcp/protocol.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! MCP protocol types for JSON-RPC 2.0 communication - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; - -/// JSON-RPC 2.0 message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcMessage { - pub jsonrpc: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub method: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -/// JSON-RPC 2.0 response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// JSON-RPC 2.0 error -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcError { - pub code: i32, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -impl std::fmt::Display for JsonRpcError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for JsonRpcError {} - -/// Initialize request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeParams { - pub protocol_version: String, - pub capabilities: ClientCapabilities, - pub client_info: ClientInfo, -} - -/// Client capabilities -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub experimental: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub sampling: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub roots: Option, -} - -/// Sampling capability -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SamplingCapability {} - -/// Roots capability -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RootsCapability { - #[serde(default)] - pub list_changed: bool, -} - -/// Client information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientInfo { - pub name: String, - pub version: String, -} - -/// Initialize result -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeResult { - pub protocol_version: String, - pub capabilities: ServerCapabilities, - pub server_info: ServerInfo, -} - -/// Server capabilities -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ServerCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub experimental: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub logging: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub prompts: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub resources: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option, -} - -/// Logging capability -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct LoggingCapability {} - -/// Prompts capability -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PromptsCapability { - #[serde(default)] - pub list_changed: bool, -} - -/// Resources capability -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResourcesCapability { - #[serde(default)] - pub subscribe: bool, - #[serde(default)] - pub list_changed: bool, -} - -/// Tools capability -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolsCapability { - #[serde(default)] - pub list_changed: bool, -} - -/// Server information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerInfo { - pub name: String, - pub version: String, -} - -/// MCP tool definition -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Tool { - pub name: String, - pub description: String, - pub input_schema: InputSchema, -} - -/// JSON schema for tool input -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InputSchema { - #[serde(rename = "type")] - pub schema_type: String, - pub properties: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option>, -} - -/// JSON schema property -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Property { - #[serde(rename = "type")] - pub property_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] - pub enum_values: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -/// Tool call parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallParams { - pub name: String, - #[serde(default)] - pub arguments: HashMap, -} - -/// Tool call result -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolCallResult { - pub content: Vec, - #[serde(default, skip_serializing_if = "is_false")] - pub is_error: bool, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -/// Content in a tool result -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Content { - #[serde(rename = "type")] - pub content_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mime_type: Option, -} - -impl Content { - /// Create a text content item - pub fn text(text: impl Into) -> Self { - Self { - content_type: "text".to_string(), - text: Some(text.into()), - data: None, - mime_type: None, - } - } - - /// Create a text content item with MIME type - pub fn text_with_mime(text: impl Into, mime_type: impl Into) -> Self { - Self { - content_type: "text".to_string(), - text: Some(text.into()), - data: None, - mime_type: Some(mime_type.into()), - } - } -} - -/// MCP resource definition -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Resource { - pub uri: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mime_type: Option, -} - -/// Resource read parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourceReadParams { - pub uri: String, -} - -/// Resource read result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourceReadResult { - pub contents: Vec, -} - -/// Resource content -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResourceContent { - pub uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub blob: Option, -} - -/// Progress notification parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProgressParams { - pub progress_token: String, - pub progress: f64, - #[serde(skip_serializing_if = "Option::is_none")] - pub total: Option, -} - -/// Log notification parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LogParams { - pub level: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub logger: Option, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_json_rpc_message_serialization() { - let msg = JsonRpcMessage { - jsonrpc: "2.0".to_string(), - method: Some("test".to_string()), - id: Some(Value::from(1)), - params: None, - }; - - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("\"jsonrpc\":\"2.0\"")); - assert!(json.contains("\"method\":\"test\"")); - } - - #[test] - fn test_tool_call_result_serialization() { - let result = ToolCallResult { - content: vec![Content::text("Hello")], - is_error: false, - }; - - let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains("\"content\"")); - assert!(!json.contains("\"isError\"")); - - let result_with_error = ToolCallResult { - content: vec![Content::text("Error")], - is_error: true, - }; - - let json = serde_json::to_string(&result_with_error).unwrap(); - assert!(json.contains("\"isError\":true")); - } - - #[test] - fn test_content_helpers() { - let text = Content::text("Hello"); - assert_eq!(text.content_type, "text"); - assert_eq!(text.text, Some("Hello".to_string())); - assert!(text.mime_type.is_none()); - - let json = Content::text_with_mime("{}", "application/json"); - assert_eq!(json.mime_type, Some("application/json".to_string())); - } -} diff --git a/thop-rust/src/mcp/server.rs b/thop-rust/src/mcp/server.rs deleted file mode 100644 index a883161..0000000 --- a/thop-rust/src/mcp/server.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! MCP server implementation - -use std::collections::HashMap; -use std::io::{self, BufRead, BufReader, Write}; -use std::sync::{Arc, Mutex}; - -use serde_json::Value; - -use crate::config::Config; -use crate::logger; -use crate::session::Manager as SessionManager; -use crate::state::Manager as StateManager; - -use super::errors::MCPError; -use super::handlers; -use super::protocol::{JsonRpcError, JsonRpcMessage, JsonRpcResponse}; - -/// MCP protocol version -pub const MCP_VERSION: &str = "2024-11-05"; - -/// Handler function type -type HandlerFn = fn(&mut Server, Option) -> Result, MCPError>; - -/// MCP Server for thop -pub struct Server { - pub config: Config, - pub sessions: SessionManager, - pub state: StateManager, - handlers: HashMap, - output: Arc>>, -} - -impl Server { - /// Create a new MCP server - pub fn new(config: Config, sessions: SessionManager, state: StateManager) -> Self { - let mut server = Self { - config, - sessions, - state, - handlers: HashMap::new(), - output: Arc::new(Mutex::new(Box::new(io::stdout()))), - }; - - server.register_handlers(); - server - } - - /// Set custom output writer (useful for testing) - pub fn set_output(&mut self, output: Box) { - self.output = Arc::new(Mutex::new(output)); - } - - /// Register all JSON-RPC method handlers - fn register_handlers(&mut self) { - // MCP protocol methods - self.handlers.insert("initialize".to_string(), handlers::handle_initialize); - self.handlers.insert("initialized".to_string(), handlers::handle_initialized); - self.handlers.insert("tools/list".to_string(), handlers::handle_tools_list); - self.handlers.insert("tools/call".to_string(), handlers::handle_tool_call); - self.handlers.insert("resources/list".to_string(), handlers::handle_resources_list); - self.handlers.insert("resources/read".to_string(), handlers::handle_resource_read); - self.handlers.insert("ping".to_string(), handlers::handle_ping); - - // Notification handlers - self.handlers.insert("cancelled".to_string(), handlers::handle_cancelled); - self.handlers.insert("progress".to_string(), handlers::handle_progress); - } - - /// Run the MCP server, reading from stdin - pub fn run(&mut self) -> crate::error::Result<()> { - logger::info("Starting MCP server"); - - let stdin = io::stdin(); - let reader = BufReader::new(stdin.lock()); - - for line in reader.lines() { - let line = line.map_err(|e| { - crate::error::ThopError::Other(format!("Failed to read input: {}", e)) - })?; - - if line.is_empty() { - continue; - } - - if let Err(e) = self.handle_message(&line) { - logger::error(&format!("Error handling message: {}", e)); - self.send_error(None, -32603, "Internal error", Some(&e.to_string())); - } - } - - Ok(()) - } - - /// Handle a single JSON-RPC message - fn handle_message(&mut self, data: &str) -> Result<(), String> { - let msg: JsonRpcMessage = serde_json::from_str(data) - .map_err(|e| format!("Failed to parse JSON-RPC message: {}", e))?; - - // Handle request if method is present - if let Some(ref method) = msg.method { - return self.handle_request(&msg, method); - } - - Ok(()) - } - - /// Handle a JSON-RPC request - fn handle_request(&mut self, msg: &JsonRpcMessage, method: &str) -> Result<(), String> { - logger::debug(&format!("Handling request: method={} id={:?}", method, msg.id)); - - let handler = match self.handlers.get(method) { - Some(h) => *h, - None => { - self.send_error( - msg.id.clone(), - -32601, - "Method not found", - Some(&format!("Unknown method: {}", method)), - ); - return Ok(()); - } - }; - - // Execute handler - match handler(self, msg.params.clone()) { - Ok(result) => { - // Send successful response if it's a request with an ID - if msg.id.is_some() { - self.send_response(msg.id.clone(), result); - } - } - Err(mcp_err) => { - // Send error response - let tool_result = mcp_err.to_tool_result(); - let result_value = serde_json::to_value(&tool_result).ok(); - self.send_response(msg.id.clone(), result_value); - } - } - - Ok(()) - } - - /// Send a JSON-RPC response - fn send_response(&self, id: Option, result: Option) { - let response = JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result, - error: None, - }; - - if let Ok(data) = serde_json::to_string(&response) { - self.write_output(&data); - } - } - - /// Send a JSON-RPC error response - fn send_error(&self, id: Option, code: i32, message: &str, data: Option<&str>) { - let response = JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { - code, - message: message.to_string(), - data: data.map(|s| Value::String(s.to_string())), - }), - }; - - if let Ok(data) = serde_json::to_string(&response) { - self.write_output(&data); - } - } - - /// Write output with newline - fn write_output(&self, data: &str) { - if let Ok(mut output) = self.output.lock() { - let _ = writeln!(output, "{}", data); - let _ = output.flush(); - } - } - - /// Send a JSON-RPC notification - #[allow(dead_code)] - pub fn send_notification(&self, method: &str, params: Option) { - let notification = JsonRpcMessage { - jsonrpc: "2.0".to_string(), - method: Some(method.to_string()), - id: None, - params, - }; - - if let Ok(data) = serde_json::to_string(¬ification) { - self.write_output(&data); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - use std::sync::{Arc, Mutex}; - - struct TestOutput { - buffer: Arc>>, - } - - impl TestOutput { - fn new() -> (Self, Arc>>) { - let buffer = Arc::new(Mutex::new(Vec::new())); - (Self { buffer: buffer.clone() }, buffer) - } - } - - impl Write for TestOutput { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.buffer.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - fn create_test_server() -> Server { - let config = Config::default(); - let state = StateManager::new(&config.settings.state_file); - let sessions = SessionManager::new(&config, Some(StateManager::new(&config.settings.state_file))); - Server::new(config, sessions, state) - } - - #[test] - fn test_server_creation() { - let server = create_test_server(); - assert!(!server.handlers.is_empty()); - } - - #[test] - fn test_handler_registration() { - let server = create_test_server(); - assert!(server.handlers.contains_key("initialize")); - assert!(server.handlers.contains_key("tools/list")); - assert!(server.handlers.contains_key("tools/call")); - assert!(server.handlers.contains_key("resources/list")); - assert!(server.handlers.contains_key("resources/read")); - assert!(server.handlers.contains_key("ping")); - } - - #[test] - fn test_send_response() { - let mut server = create_test_server(); - let (output, buffer) = TestOutput::new(); - server.set_output(Box::new(output)); - - server.send_response(Some(Value::from(1)), Some(Value::String("test".to_string()))); - - let output = buffer.lock().unwrap(); - let response: JsonRpcResponse = serde_json::from_slice(&output).unwrap(); - assert_eq!(response.jsonrpc, "2.0"); - assert_eq!(response.id, Some(Value::from(1))); - assert_eq!(response.result, Some(Value::String("test".to_string()))); - } - - #[test] - fn test_send_error() { - let mut server = create_test_server(); - let (output, buffer) = TestOutput::new(); - server.set_output(Box::new(output)); - - server.send_error(Some(Value::from(1)), -32601, "Method not found", Some("test")); - - let output = buffer.lock().unwrap(); - let response: JsonRpcResponse = serde_json::from_slice(&output).unwrap(); - assert_eq!(response.jsonrpc, "2.0"); - assert!(response.error.is_some()); - assert_eq!(response.error.as_ref().unwrap().code, -32601); - } - - #[test] - fn test_handle_unknown_method() { - let mut server = create_test_server(); - let (output, buffer) = TestOutput::new(); - server.set_output(Box::new(output)); - - let msg = JsonRpcMessage { - jsonrpc: "2.0".to_string(), - method: Some("unknown_method".to_string()), - id: Some(Value::from(1)), - params: None, - }; - - let _ = server.handle_request(&msg, "unknown_method"); - - let output = buffer.lock().unwrap(); - let response: JsonRpcResponse = serde_json::from_slice(&output).unwrap(); - assert!(response.error.is_some()); - assert_eq!(response.error.as_ref().unwrap().code, -32601); - } -} diff --git a/thop-rust/src/mcp/tools.rs b/thop-rust/src/mcp/tools.rs deleted file mode 100644 index c69c3ef..0000000 --- a/thop-rust/src/mcp/tools.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! MCP tool implementations - -use std::collections::HashMap; - -use serde_json::Value; - -use super::errors::{ErrorCode, MCPError}; -use super::protocol::{Content, InputSchema, Property, Tool, ToolCallResult}; -use super::server::Server; - -/// Get all tool definitions -pub fn get_tool_definitions() -> Vec { - vec![ - // Session management tools - Tool { - name: "connect".to_string(), - description: "Connect to an SSH session".to_string(), - input_schema: InputSchema { - schema_type: "object".to_string(), - properties: { - let mut props = HashMap::new(); - props.insert( - "session".to_string(), - Property { - property_type: "string".to_string(), - description: Some("Name of the session to connect to".to_string()), - enum_values: None, - default: None, - }, - ); - props - }, - required: Some(vec!["session".to_string()]), - }, - }, - Tool { - name: "switch".to_string(), - description: "Switch to a different session".to_string(), - input_schema: InputSchema { - schema_type: "object".to_string(), - properties: { - let mut props = HashMap::new(); - props.insert( - "session".to_string(), - Property { - property_type: "string".to_string(), - description: Some("Name of the session to switch to".to_string()), - enum_values: None, - default: None, - }, - ); - props - }, - required: Some(vec!["session".to_string()]), - }, - }, - Tool { - name: "close".to_string(), - description: "Close an SSH session".to_string(), - input_schema: InputSchema { - schema_type: "object".to_string(), - properties: { - let mut props = HashMap::new(); - props.insert( - "session".to_string(), - Property { - property_type: "string".to_string(), - description: Some("Name of the session to close".to_string()), - enum_values: None, - default: None, - }, - ); - props - }, - required: Some(vec!["session".to_string()]), - }, - }, - Tool { - name: "status".to_string(), - description: "Get status of all sessions".to_string(), - input_schema: InputSchema { - schema_type: "object".to_string(), - properties: HashMap::new(), - required: None, - }, - }, - // Command execution tool - Tool { - name: "execute".to_string(), - description: "Execute a command in the active session (optionally in background)".to_string(), - input_schema: InputSchema { - schema_type: "object".to_string(), - properties: { - let mut props = HashMap::new(); - props.insert( - "command".to_string(), - Property { - property_type: "string".to_string(), - description: Some("Command to execute".to_string()), - enum_values: None, - default: None, - }, - ); - props.insert( - "session".to_string(), - Property { - property_type: "string".to_string(), - description: Some("Optional: specific session to execute in (uses active session if not specified)".to_string()), - enum_values: None, - default: None, - }, - ); - props.insert( - "timeout".to_string(), - Property { - property_type: "integer".to_string(), - description: Some("Optional: command timeout in seconds (ignored if background is true)".to_string()), - enum_values: None, - default: Some(Value::from(300)), - }, - ); - props.insert( - "background".to_string(), - Property { - property_type: "boolean".to_string(), - description: Some("Optional: run command in background (default: false)".to_string()), - enum_values: None, - default: Some(Value::Bool(false)), - }, - ); - props - }, - required: Some(vec!["command".to_string()]), - }, - }, - ] -} - -/// Handle connect tool -pub fn tool_connect(server: &mut Server, args: HashMap) -> ToolCallResult { - let session_name = match args.get("session").and_then(|v| v.as_str()) { - Some(s) => s, - None => return MCPError::missing_parameter("session").to_tool_result(), - }; - - if let Err(e) = server.sessions.connect(session_name) { - let err_str = e.to_string(); - - // Check for specific error patterns - if err_str.contains("not found") || err_str.contains("does not exist") { - return MCPError::session_not_found(session_name).to_tool_result(); - } - if err_str.contains("key") && err_str.contains("auth") { - return MCPError::auth_key_failed(session_name).to_tool_result(); - } - if err_str.contains("password") { - return MCPError::auth_password_failed(session_name).to_tool_result(); - } - if err_str.contains("host key") || err_str.contains("known_hosts") { - return MCPError::host_key_unknown(session_name).to_tool_result(); - } - if err_str.contains("timeout") { - return MCPError::new(ErrorCode::ConnectionTimeout, "Connection timed out") - .with_session(session_name) - .with_suggestion("Check network connectivity and firewall settings") - .to_tool_result(); - } - if err_str.contains("refused") { - return MCPError::new(ErrorCode::ConnectionRefused, "Connection refused") - .with_session(session_name) - .with_suggestion("Verify the host and port are correct") - .to_tool_result(); - } - - return MCPError::connection_failed(session_name, &err_str).to_tool_result(); - } - - ToolCallResult { - content: vec![Content::text(format!( - "Successfully connected to session '{}'", - session_name - ))], - is_error: false, - } -} - -/// Handle switch tool -pub fn tool_switch(server: &mut Server, args: HashMap) -> ToolCallResult { - let session_name = match args.get("session").and_then(|v| v.as_str()) { - Some(s) => s, - None => return MCPError::missing_parameter("session").to_tool_result(), - }; - - if let Err(e) = server.sessions.set_active_session(session_name) { - let err_str = e.to_string(); - - if err_str.contains("not found") { - return MCPError::session_not_found(session_name).to_tool_result(); - } - if err_str.contains("not connected") { - return MCPError::session_not_connected(session_name).to_tool_result(); - } - - return MCPError::new(ErrorCode::OperationFailed, format!("Failed to switch session: {}", e)) - .with_session(session_name) - .to_tool_result(); - } - - // Get session info - let cwd = server - .sessions - .get_session(session_name) - .map(|s| s.get_cwd().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - ToolCallResult { - content: vec![Content::text(format!( - "Switched to session '{}' (cwd: {})", - session_name, cwd - ))], - is_error: false, - } -} - -/// Handle close tool -pub fn tool_close(server: &mut Server, args: HashMap) -> ToolCallResult { - let session_name = match args.get("session").and_then(|v| v.as_str()) { - Some(s) => s, - None => return MCPError::missing_parameter("session").to_tool_result(), - }; - - if let Err(e) = server.sessions.disconnect(session_name) { - let err_str = e.to_string(); - - if err_str.contains("not found") { - return MCPError::session_not_found(session_name).to_tool_result(); - } - if err_str.contains("cannot close local") || err_str.contains("local session") { - return MCPError::cannot_close_local(session_name).to_tool_result(); - } - - return MCPError::new(ErrorCode::OperationFailed, format!("Failed to close session: {}", e)) - .with_session(session_name) - .to_tool_result(); - } - - ToolCallResult { - content: vec![Content::text(format!("Session '{}' closed", session_name))], - is_error: false, - } -} - -/// Handle status tool -pub fn tool_status(server: &mut Server, _args: HashMap) -> ToolCallResult { - let sessions = server.sessions.list_sessions(); - - match serde_json::to_string_pretty(&sessions) { - Ok(data) => ToolCallResult { - content: vec![Content::text_with_mime(data, "application/json")], - is_error: false, - }, - Err(e) => MCPError::new(ErrorCode::OperationFailed, format!("Failed to format status: {}", e)) - .with_suggestion("Check system resources and try again") - .to_tool_result(), - } -} - -/// Handle execute tool -pub fn tool_execute(server: &mut Server, args: HashMap) -> ToolCallResult { - let command = match args.get("command").and_then(|v| v.as_str()) { - Some(s) => s, - None => return MCPError::missing_parameter("command").to_tool_result(), - }; - - let session_name = args.get("session").and_then(|v| v.as_str()); - let background = args.get("background").and_then(|v| v.as_bool()).unwrap_or(false); - let _timeout = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(300); - - // Handle background execution - if background { - return MCPError::not_implemented("Background execution").to_tool_result(); - } - - // Execute the command - let result = if let Some(name) = session_name { - if !server.sessions.has_session(name) { - return MCPError::session_not_found(name).to_tool_result(); - } - server.sessions.execute_on(name, command) - } else { - server.sessions.execute(command) - }; - - let active_session = session_name - .map(|s| s.to_string()) - .unwrap_or_else(|| server.sessions.get_active_session_name().to_string()); - - match result { - Ok(exec_result) => { - let mut content = vec![]; - - // Add stdout if present - if !exec_result.stdout.is_empty() { - content.push(Content::text(&exec_result.stdout)); - } - - // Add stderr if present - if !exec_result.stderr.is_empty() { - content.push(Content::text(format!("stderr:\n{}", exec_result.stderr))); - } - - // Add exit code if non-zero - if exec_result.exit_code != 0 { - content.push(Content::text(format!("Exit code: {}", exec_result.exit_code))); - } - - // If no output at all, indicate success - if content.is_empty() { - content.push(Content::text("Command executed successfully (no output)")); - } - - ToolCallResult { - content, - is_error: exec_result.exit_code != 0, - } - } - Err(e) => { - let err_str = e.to_string(); - - // Check for timeout - if err_str.contains("timeout") { - return MCPError::command_timeout(&active_session, _timeout).to_tool_result(); - } - - // Check for permission denied - if err_str.contains("permission denied") { - return MCPError::new(ErrorCode::PermissionDenied, "Permission denied") - .with_session(&active_session) - .with_suggestion("Check file/directory permissions or use sudo if appropriate") - .to_tool_result(); - } - - // Check for command not found - if err_str.contains("command not found") || err_str.contains("not found") { - return MCPError::new(ErrorCode::CommandNotFound, format!("Command not found: {}", command)) - .with_session(&active_session) - .with_suggestion("Verify the command is installed and in PATH") - .to_tool_result(); - } - - // Generic command failure - MCPError::new(ErrorCode::CommandFailed, err_str) - .with_session(&active_session) - .to_tool_result() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::session::Manager as SessionManager; - use crate::state::Manager as StateManager; - - fn create_test_server() -> Server { - let config = Config::default(); - let state = StateManager::new(&config.settings.state_file); - let sessions = SessionManager::new(&config, Some(StateManager::new(&config.settings.state_file))); - Server::new(config, sessions, state) - } - - #[test] - fn test_get_tool_definitions() { - let tools = get_tool_definitions(); - assert!(!tools.is_empty()); - - // Check for required tools - let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); - assert!(tool_names.contains(&"connect")); - assert!(tool_names.contains(&"switch")); - assert!(tool_names.contains(&"close")); - assert!(tool_names.contains(&"status")); - assert!(tool_names.contains(&"execute")); - } - - #[test] - fn test_tool_status() { - let mut server = create_test_server(); - let result = tool_status(&mut server, HashMap::new()); - assert!(!result.is_error); - assert!(!result.content.is_empty()); - } - - #[test] - fn test_tool_connect_missing_session() { - let mut server = create_test_server(); - let result = tool_connect(&mut server, HashMap::new()); - assert!(result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("MISSING_PARAMETER")); - } - - #[test] - fn test_tool_switch_missing_session() { - let mut server = create_test_server(); - let result = tool_switch(&mut server, HashMap::new()); - assert!(result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("MISSING_PARAMETER")); - } - - #[test] - fn test_tool_close_missing_session() { - let mut server = create_test_server(); - let result = tool_close(&mut server, HashMap::new()); - assert!(result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("MISSING_PARAMETER")); - } - - #[test] - fn test_tool_execute_missing_command() { - let mut server = create_test_server(); - let result = tool_execute(&mut server, HashMap::new()); - assert!(result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("MISSING_PARAMETER")); - } - - #[test] - fn test_tool_execute_local() { - let mut server = create_test_server(); - let mut args = HashMap::new(); - args.insert("command".to_string(), Value::String("echo hello".to_string())); - - let result = tool_execute(&mut server, args); - assert!(!result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("hello")); - } - - #[test] - fn test_tool_switch_local() { - let mut server = create_test_server(); - let mut args = HashMap::new(); - args.insert("session".to_string(), Value::String("local".to_string())); - - let result = tool_switch(&mut server, args); - assert!(!result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("Switched to session 'local'")); - } - - #[test] - fn test_tool_connect_nonexistent() { - let mut server = create_test_server(); - let mut args = HashMap::new(); - args.insert("session".to_string(), Value::String("nonexistent".to_string())); - - let result = tool_connect(&mut server, args); - assert!(result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("SESSION_NOT_FOUND")); - } - - #[test] - fn test_tool_execute_background_not_implemented() { - let mut server = create_test_server(); - let mut args = HashMap::new(); - args.insert("command".to_string(), Value::String("sleep 10".to_string())); - args.insert("background".to_string(), Value::Bool(true)); - - let result = tool_execute(&mut server, args); - assert!(result.is_error); - assert!(result.content[0].text.as_ref().unwrap().contains("NOT_IMPLEMENTED")); - } -} diff --git a/thop-rust/src/restriction.rs b/thop-rust/src/restriction.rs deleted file mode 100644 index df42985..0000000 --- a/thop-rust/src/restriction.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! Command restriction module for blocking dangerous/destructive operations. -//! -//! This module provides a `Checker` that validates commands against a set of -//! restriction rules, preventing AI agents from executing dangerous commands -//! like `rm -rf`, `sudo`, etc. - -use regex::Regex; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// Category of restricted commands -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Category { - /// Commands that escalate privileges (sudo, su, doas) - PrivilegeEscalation, - /// Commands that delete files (rm, rmdir, shred) - DestructiveFile, - /// Commands that modify system configuration (chmod, mount, etc.) - SystemModification, -} - -impl Category { - /// Get a human-readable description for this category - pub fn description(&self) -> &'static str { - match self { - Category::PrivilegeEscalation => "Privilege escalation", - Category::DestructiveFile => "Destructive file operation", - Category::SystemModification => "System modification", - } - } -} - -/// A restriction rule that matches dangerous commands -pub struct Rule { - pattern: Regex, - category: Category, - command: String, - #[allow(dead_code)] - description: String, -} - -impl Rule { - fn new(pattern: &str, category: Category, command: &str, description: &str) -> Self { - Self { - pattern: Regex::new(pattern).expect("Invalid regex pattern"), - category, - command: command.to_string(), - description: description.to_string(), - } - } -} - -/// Result of checking a command against restriction rules -pub struct CheckResult<'a> { - pub allowed: bool, - pub rule: Option<&'a Rule>, -} - -impl<'a> CheckResult<'a> { - /// Get the command name that was blocked - pub fn command(&self) -> Option<&str> { - self.rule.map(|r| r.command.as_str()) - } - - /// Get the category of the blocked command - pub fn category(&self) -> Option { - self.rule.map(|r| r.category) - } -} - -/// Checker validates commands against restriction rules -pub struct Checker { - rules: Vec, - enabled: AtomicBool, -} - -impl Default for Checker { - fn default() -> Self { - Self::new() - } -} - -impl Checker { - /// Create a new restriction checker with default rules - pub fn new() -> Self { - let mut rules = Vec::new(); - - // Add privilege escalation rules - rules.extend(build_privilege_escalation_rules()); - - // Add destructive file operation rules - rules.extend(build_destructive_file_rules()); - - // Add system modification rules - rules.extend(build_system_modification_rules()); - - Self { - rules, - enabled: AtomicBool::new(false), - } - } - - /// Enable or disable restriction checking - pub fn set_enabled(&self, enabled: bool) { - self.enabled.store(enabled, Ordering::SeqCst); - } - - /// Check if restriction checking is enabled - pub fn is_enabled(&self) -> bool { - self.enabled.load(Ordering::SeqCst) - } - - /// Check if a command is allowed - /// - /// Returns a `CheckResult` indicating whether the command is allowed - /// and which rule blocked it (if any). - pub fn check(&self, cmd: &str) -> CheckResult<'_> { - if !self.is_enabled() { - return CheckResult { allowed: true, rule: None }; - } - - let cmd = cmd.trim(); - if cmd.is_empty() { - return CheckResult { allowed: true, rule: None }; - } - - for rule in &self.rules { - if rule.pattern.is_match(cmd) { - return CheckResult { - allowed: false, - rule: Some(rule), - }; - } - } - - CheckResult { allowed: true, rule: None } - } -} - -/// Build privilege escalation rules (sudo, su, doas, pkexec) -fn build_privilege_escalation_rules() -> Vec { - let commands = [ - ("sudo", "execute commands with superuser privileges"), - ("su", "switch user identity"), - ("doas", "execute commands as another user"), - ("pkexec", "execute commands as another user via PolicyKit"), - ]; - - commands - .iter() - .map(|(cmd, desc)| { - // Match command at start of line, or after pipe/semicolon/&&/|| - let pattern = format!(r"(?:^|[|;&])\s*{}\s", regex::escape(cmd)); - Rule::new(&pattern, Category::PrivilegeEscalation, cmd, desc) - }) - .collect() -} - -/// Build destructive file operation rules (rm, rmdir, shred, etc.) -fn build_destructive_file_rules() -> Vec { - let commands = [ - ("rm", "remove files or directories"), - ("rmdir", "remove empty directories"), - ("shred", "securely delete files"), - ("wipe", "securely erase files"), - ("srm", "secure remove"), - ("unlink", "remove files"), - ("dd", "copy and convert files (can overwrite disks)"), - ]; - - let mut rules: Vec = commands - .iter() - .map(|(cmd, desc)| { - let pattern = format!(r"(?:^|[|;&])\s*{}\s", regex::escape(cmd)); - Rule::new(&pattern, Category::DestructiveFile, cmd, desc) - }) - .collect(); - - // Special case: truncate with size 0 (destructive) - rules.push(Rule::new( - r"(?:^|[|;&])\s*truncate\s+.*-s\s*0", - Category::DestructiveFile, - "truncate", - "truncate files to zero size", - )); - - // Special case: > file (redirecting nothing to file, truncates it) - rules.push(Rule::new( - r"(?:^|[|;&])\s*>\s*\S", - Category::DestructiveFile, - "> redirect", - "truncate file via redirect", - )); - - rules -} - -/// Build system modification rules (chmod, mount, shutdown, etc.) -fn build_system_modification_rules() -> Vec { - let commands = [ - // Permission/ownership changes - ("chmod", "change file permissions"), - ("chown", "change file ownership"), - ("chgrp", "change file group ownership"), - ("chattr", "change file attributes"), - // Disk/filesystem operations - ("fdisk", "partition table manipulator"), - ("parted", "partition editor"), - ("mount", "mount filesystems"), - ("umount", "unmount filesystems"), - ("fsck", "filesystem check and repair"), - // System control - ("shutdown", "shutdown the system"), - ("reboot", "reboot the system"), - ("poweroff", "power off the system"), - ("halt", "halt the system"), - ("init", "change runlevel"), - // User/group management - ("useradd", "create user accounts"), - ("userdel", "delete user accounts"), - ("usermod", "modify user accounts"), - ("groupadd", "create groups"), - ("groupdel", "delete groups"), - ("groupmod", "modify groups"), - ("passwd", "change user password"), - // Service management - ("systemctl", "control systemd services"), - ("service", "control system services"), - // Kernel/module operations - ("insmod", "insert kernel module"), - ("rmmod", "remove kernel module"), - ("modprobe", "add/remove kernel modules"), - // SELinux/AppArmor - ("setenforce", "modify SELinux mode"), - ("aa-enforce", "set AppArmor profile to enforce"), - ("aa-complain", "set AppArmor profile to complain"), - ]; - - let mut rules: Vec = commands - .iter() - .map(|(cmd, desc)| { - let pattern = format!(r"(?:^|[|;&])\s*{}\s", regex::escape(cmd)); - Rule::new(&pattern, Category::SystemModification, cmd, desc) - }) - .collect(); - - // Special case: mkfs and variants (mkfs.ext4, mkfs.xfs, etc.) - rules.push(Rule::new( - r"(?:^|[|;&])\s*mkfs(?:\.\w+)?\s", - Category::SystemModification, - "mkfs", - "create filesystem (formats disk)", - )); - - rules -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_disabled_by_default() { - let checker = Checker::new(); - assert!(!checker.is_enabled()); - - let result = checker.check("rm -rf /"); - assert!(result.allowed); - assert!(result.rule.is_none()); - } - - #[test] - fn test_enable_disable() { - let checker = Checker::new(); - - checker.set_enabled(true); - assert!(checker.is_enabled()); - - checker.set_enabled(false); - assert!(!checker.is_enabled()); - } - - #[test] - fn test_privilege_escalation() { - let checker = Checker::new(); - checker.set_enabled(true); - - // Should be blocked - assert!(!checker.check("sudo ls").allowed); - assert!(!checker.check("sudo apt-get update").allowed); - assert!(!checker.check("echo foo | sudo tee /etc/file").allowed); - assert!(!checker.check("ls; sudo rm file").allowed); - assert!(!checker.check("cd /tmp && sudo chmod 777 file").allowed); - assert!(!checker.check("su -").allowed); - assert!(!checker.check("su - root").allowed); - assert!(!checker.check("doas ls").allowed); - assert!(!checker.check("pkexec apt update").allowed); - - // Should be allowed - assert!(checker.check("cat /etc/sudoers").allowed); - assert!(checker.check("echo 'use sudo to...'").allowed); - assert!(checker.check("result=success").allowed); - assert!(checker.check("resume").allowed); - } - - #[test] - fn test_destructive_file_ops() { - let checker = Checker::new(); - checker.set_enabled(true); - - // Should be blocked - assert!(!checker.check("rm file.txt").allowed); - assert!(!checker.check("rm -rf /tmp/dir").allowed); - assert!(!checker.check("rm -f important.txt").allowed); - assert!(!checker.check("rmdir empty_dir").allowed); - assert!(!checker.check("shred secret.txt").allowed); - assert!(!checker.check("unlink symlink").allowed); - assert!(!checker.check("dd if=/dev/zero of=/dev/sda").allowed); - assert!(!checker.check("wipe -f disk").allowed); - assert!(!checker.check("ls && rm file").allowed); - assert!(!checker.check("truncate -s 0 important.log").allowed); - - // Should be allowed - assert!(checker.check("mkdir new_dir").allowed); - assert!(checker.check("touch new_file").allowed); - assert!(checker.check("mv old.txt new.txt").allowed); - assert!(checker.check("cp source.txt dest.txt").allowed); - assert!(checker.check("ls -la").allowed); - assert!(checker.check("cat file.txt").allowed); - assert!(checker.check("grep 'rm' script.sh").allowed); - assert!(checker.check("echo 'do not rm this'").allowed); - } - - #[test] - fn test_system_modifications() { - let checker = Checker::new(); - checker.set_enabled(true); - - // Should be blocked - assert!(!checker.check("chmod 755 script.sh").allowed); - assert!(!checker.check("chmod 777 /var/www").allowed); - assert!(!checker.check("chown root:root file").allowed); - assert!(!checker.check("chgrp admin file").allowed); - assert!(!checker.check("mkfs /dev/sdb1").allowed); - assert!(!checker.check("mkfs.ext4 /dev/sdb1").allowed); - assert!(!checker.check("mkfs.xfs /dev/sdc1").allowed); - assert!(!checker.check("fdisk /dev/sda").allowed); - assert!(!checker.check("mount /dev/sdb1 /mnt").allowed); - assert!(!checker.check("umount /mnt").allowed); - assert!(!checker.check("shutdown -h now").allowed); - assert!(!checker.check("reboot now").allowed); - assert!(!checker.check("poweroff now").allowed); - assert!(!checker.check("useradd newuser").allowed); - assert!(!checker.check("userdel olduser").allowed); - assert!(!checker.check("usermod -aG docker user").allowed); - assert!(!checker.check("passwd user").allowed); - assert!(!checker.check("systemctl stop nginx").allowed); - assert!(!checker.check("systemctl start docker").allowed); - assert!(!checker.check("service apache2 restart").allowed); - assert!(!checker.check("insmod module.ko").allowed); - assert!(!checker.check("rmmod module").allowed); - assert!(!checker.check("modprobe driver").allowed); - - // Should be allowed - assert!(checker.check("ls -la").allowed); - assert!(checker.check("stat file.txt").allowed); - assert!(checker.check("id").allowed); - assert!(checker.check("whoami").allowed); - } - - #[test] - fn test_empty_and_whitespace() { - let checker = Checker::new(); - checker.set_enabled(true); - - assert!(checker.check("").allowed); - assert!(checker.check(" ").allowed); - assert!(checker.check("\t\t").allowed); - assert!(checker.check("\n\n").allowed); - } - - #[test] - fn test_complex_commands() { - let checker = Checker::new(); - checker.set_enabled(true); - - // Complex blocked commands - assert!(!checker.check("cd /tmp && rm -rf *").allowed); - assert!(!checker.check("rm -rf dir &").allowed); - assert!(!checker.check("rm file 2>/dev/null").allowed); - - // Complex allowed commands - assert!(checker.check("cat file | grep pattern | wc -l").allowed); - assert!(checker.check("echo $(date)").allowed); - assert!(checker.check("pwd && ls && echo done").allowed); - assert!(checker.check("sleep 10 &").allowed); - } - - #[test] - fn test_category_description() { - assert_eq!(Category::PrivilegeEscalation.description(), "Privilege escalation"); - assert_eq!(Category::DestructiveFile.description(), "Destructive file operation"); - assert_eq!(Category::SystemModification.description(), "System modification"); - } - - #[test] - fn test_check_result_accessors() { - let checker = Checker::new(); - checker.set_enabled(true); - - let result = checker.check("sudo ls"); - assert!(!result.allowed); - assert_eq!(result.command(), Some("sudo")); - assert_eq!(result.category(), Some(Category::PrivilegeEscalation)); - - let result = checker.check("rm file"); - assert!(!result.allowed); - assert_eq!(result.command(), Some("rm")); - assert_eq!(result.category(), Some(Category::DestructiveFile)); - - let result = checker.check("ls -la"); - assert!(result.allowed); - assert!(result.command().is_none()); - assert!(result.category().is_none()); - } -} diff --git a/thop-rust/src/session/local.rs b/thop-rust/src/session/local.rs deleted file mode 100644 index 587539f..0000000 --- a/thop-rust/src/session/local.rs +++ /dev/null @@ -1,331 +0,0 @@ -use std::collections::HashMap; -use std::env; -use std::path::PathBuf; -use std::process::Command; - -use crate::error::Result; -use super::{ExecuteResult, Session}; - -/// Local shell session -pub struct LocalSession { - name: String, - shell: String, - cwd: String, - env: HashMap, - connected: bool, -} - -impl LocalSession { - /// Create a new local session - pub fn new(name: impl Into, shell: Option) -> Self { - let shell = shell.unwrap_or_else(|| { - env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) - }); - - let cwd = env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| { - dirs::home_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| "/".to_string()) - }); - - Self { - name: name.into(), - shell, - cwd, - env: HashMap::new(), - connected: true, // Local is always "connected" - } - } - - /// Handle cd commands specially to track cwd - fn handle_cd(&mut self, cmd: &str) -> Result { - let parts: Vec<&str> = cmd.split_whitespace().collect(); - - let target_dir = if parts.len() == 1 { - // cd with no args goes to home - match dirs::home_dir() { - Some(p) => p.to_string_lossy().to_string(), - None => { - return Ok(ExecuteResult { - stderr: "cd: HOME not set\n".to_string(), - exit_code: 1, - ..Default::default() - }); - } - } - } else { - let target = parts[1]; - - // Handle ~ expansion - let expanded = if target.starts_with('~') { - let home = dirs::home_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| "~".to_string()); - target.replacen('~', &home, 1) - } else { - target.to_string() - }; - - // Handle relative paths - if expanded.starts_with('/') { - expanded - } else { - format!("{}/{}", self.cwd, expanded) - } - }; - - // Check if directory exists - let path = PathBuf::from(&target_dir); - if !path.exists() { - return Ok(ExecuteResult { - stderr: format!("cd: {}: No such file or directory\n", target_dir), - exit_code: 1, - ..Default::default() - }); - } - - if !path.is_dir() { - return Ok(ExecuteResult { - stderr: format!("cd: {}: Not a directory\n", target_dir), - exit_code: 1, - ..Default::default() - }); - } - - // Get the canonical path - let output = Command::new(&self.shell) - .arg("-c") - .arg(format!("cd {} && pwd", target_dir)) - .output(); - - match output { - Ok(output) if output.status.success() => { - self.cwd = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(ExecuteResult::default()) - } - Ok(output) => Ok(ExecuteResult { - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - exit_code: output.status.code().unwrap_or(1), - ..Default::default() - }), - Err(e) => Ok(ExecuteResult { - stderr: format!("cd: {}: {}\n", target_dir, e), - exit_code: 1, - ..Default::default() - }), - } - } - - /// Set the shell to use - pub fn set_shell(&mut self, shell: impl Into) { - self.shell = shell.into(); - } -} - -impl Session for LocalSession { - fn name(&self) -> &str { - &self.name - } - - fn session_type(&self) -> &str { - "local" - } - - fn is_connected(&self) -> bool { - self.connected - } - - fn connect(&mut self) -> Result<()> { - self.connected = true; - Ok(()) - } - - fn disconnect(&mut self) -> Result<()> { - self.connected = false; - Ok(()) - } - - fn execute(&mut self, cmd: &str) -> Result { - let trimmed = cmd.trim(); - - // Handle cd commands specially - if trimmed == "cd" || trimmed.starts_with("cd ") { - return self.handle_cd(cmd); - } - - // Execute command via shell - let mut command = Command::new(&self.shell); - command.arg("-c").arg(cmd).current_dir(&self.cwd); - - // Set environment - for (key, value) in &self.env { - command.env(key, value); - } - - let output = command.output()?; - - Ok(ExecuteResult { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - exit_code: output.status.code().unwrap_or(-1), - }) - } - - fn get_cwd(&self) -> &str { - &self.cwd - } - - fn set_cwd(&mut self, path: &str) -> Result<()> { - let path_buf = PathBuf::from(path); - if !path_buf.exists() || !path_buf.is_dir() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Directory not found", - ) - .into()); - } - self.cwd = path.to_string(); - Ok(()) - } - - fn get_env(&self) -> HashMap { - self.env.clone() - } - - fn set_env(&mut self, key: &str, value: &str) { - self.env.insert(key.to_string(), value.to_string()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_new_local_session() { - let session = LocalSession::new("test", Some("/bin/bash".to_string())); - assert_eq!(session.name(), "test"); - assert_eq!(session.session_type(), "local"); - assert!(session.is_connected()); - assert!(!session.get_cwd().is_empty()); - } - - #[test] - fn test_default_shell() { - let session = LocalSession::new("test", None); - // Should use SHELL env or /bin/sh - assert!(!session.shell.is_empty()); - } - - #[test] - fn test_connect_disconnect() { - let mut session = LocalSession::new("test", None); - - session.disconnect().unwrap(); - assert!(!session.is_connected()); - - session.connect().unwrap(); - assert!(session.is_connected()); - } - - #[test] - fn test_execute() { - let mut session = LocalSession::new("test", None); - - let result = session.execute("echo hello").unwrap(); - assert_eq!(result.stdout.trim(), "hello"); - assert_eq!(result.exit_code, 0); - } - - #[test] - fn test_execute_failing_command() { - let mut session = LocalSession::new("test", None); - - let result = session.execute("exit 42").unwrap(); - assert_eq!(result.exit_code, 42); - } - - #[test] - fn test_execute_stderr() { - let mut session = LocalSession::new("test", None); - - let result = session.execute("echo error >&2").unwrap(); - assert!(result.stderr.contains("error")); - } - - #[test] - fn test_cd() { - let mut session = LocalSession::new("test", None); - let original_cwd = session.get_cwd().to_string(); - - // cd to /tmp - let result = session.execute("cd /tmp").unwrap(); - assert_eq!(result.exit_code, 0); - // On macOS, /tmp is a symlink to /private/tmp, so accept either - let cwd = session.get_cwd(); - assert!( - cwd == "/tmp" || cwd == "/private/tmp", - "expected '/tmp' or '/private/tmp', got '{}'", - cwd - ); - - // pwd should return /tmp (or /private/tmp on macOS) - let result = session.execute("pwd").unwrap(); - let pwd_output = result.stdout.trim(); - assert!( - pwd_output == "/tmp" || pwd_output == "/private/tmp", - "expected '/tmp' or '/private/tmp', got '{}'", - pwd_output - ); - - // cd with no args goes to home - session.execute("cd").unwrap(); - let home = dirs::home_dir().unwrap().to_string_lossy().to_string(); - assert_eq!(session.get_cwd(), home); - - // Restore - session.execute(&format!("cd {}", original_cwd)).ok(); - } - - #[test] - fn test_cd_nonexistent() { - let mut session = LocalSession::new("test", None); - let original_cwd = session.get_cwd().to_string(); - - let result = session.execute("cd /nonexistent_path_12345").unwrap(); - assert_ne!(result.exit_code, 0); - assert!(result.stderr.contains("No such file")); - assert_eq!(session.get_cwd(), original_cwd); - } - - #[test] - fn test_env() { - let mut session = LocalSession::new("test", None); - - session.set_env("TEST_VAR", "test_value"); - let env = session.get_env(); - assert_eq!(env.get("TEST_VAR").unwrap(), "test_value"); - - let result = session.execute("echo $TEST_VAR").unwrap(); - assert_eq!(result.stdout.trim(), "test_value"); - } - - #[test] - fn test_set_cwd() { - let mut session = LocalSession::new("test", None); - - session.set_cwd("/tmp").unwrap(); - // On macOS, /tmp is a symlink to /private/tmp, so accept either - let cwd = session.get_cwd(); - assert!( - cwd == "/tmp" || cwd == "/private/tmp", - "expected '/tmp' or '/private/tmp', got '{}'", - cwd - ); - - let err = session.set_cwd("/nonexistent_12345"); - assert!(err.is_err()); - } -} diff --git a/thop-rust/src/session/manager.rs b/thop-rust/src/session/manager.rs deleted file mode 100644 index 9fc9b4a..0000000 --- a/thop-rust/src/session/manager.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::collections::HashMap; - -use serde::Serialize; - -use crate::config::Config; -use crate::error::{Result, SessionError}; -use crate::restriction::Checker as RestrictionChecker; -use crate::sshconfig::SshConfigParser; -use crate::state::Manager as StateManager; -use super::{ExecuteResult, LocalSession, Session, SshConfig, SshSession}; - -/// Session info for listing -#[derive(Debug, Clone, Serialize)] -pub struct SessionInfo { - pub name: String, - #[serde(rename = "type")] - pub session_type: String, - pub connected: bool, - pub active: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, - pub cwd: String, -} - -/// Session manager -pub struct Manager { - sessions: HashMap>, - active_session: String, - state_manager: Option, - restriction_checker: RestrictionChecker, -} - -impl Manager { - /// Create a new session manager from config - pub fn new(config: &Config, state_manager: Option) -> Self { - let mut sessions: HashMap> = HashMap::new(); - - // Load SSH config for host resolution - let ssh_config_parser = SshConfigParser::new(); - - // Create sessions from config - for (name, session_config) in &config.sessions { - let session: Box = match session_config.session_type.as_str() { - "local" => Box::new(LocalSession::new( - name.clone(), - session_config.shell.clone(), - )), - "ssh" => { - // Get host from config or use session name as alias - let host_alias = session_config.host.clone().unwrap_or_else(|| name.clone()); - - // Resolve host settings from ~/.ssh/config - let resolved_host = ssh_config_parser.resolve_hostname(&host_alias); - let resolved_user = session_config.user.clone() - .or_else(|| ssh_config_parser.resolve_user(&host_alias)) - .unwrap_or_else(|| { - std::env::var("USER").unwrap_or_else(|_| "root".to_string()) - }); - let resolved_port = session_config.port - .unwrap_or_else(|| ssh_config_parser.resolve_port(&host_alias)); - let resolved_identity = session_config.identity_file.clone() - .or_else(|| ssh_config_parser.resolve_identity_file(&host_alias)); - - let ssh_config = SshConfig { - host: resolved_host, - user: resolved_user, - port: resolved_port, - identity_file: resolved_identity, - password: None, - }; - Box::new(SshSession::new(name.clone(), ssh_config)) - } - _ => continue, - }; - sessions.insert(name.clone(), session); - } - - // Get active session from state or config default - let active_session = state_manager - .as_ref() - .map(|s| s.get_active_session()) - .unwrap_or_else(|| config.settings.default_session.clone()); - - Self { - sessions, - active_session, - state_manager, - restriction_checker: RestrictionChecker::new(), - } - } - - /// Check if a session exists - pub fn has_session(&self, name: &str) -> bool { - self.sessions.contains_key(name) - } - - /// Enable or disable restricted mode - pub fn set_restricted_mode(&self, enabled: bool) { - self.restriction_checker.set_enabled(enabled); - } - - /// Check if restricted mode is enabled - pub fn is_restricted_mode(&self) -> bool { - self.restriction_checker.is_enabled() - } - - /// Get a session by name - pub fn get_session(&self, name: &str) -> Option<&dyn Session> { - self.sessions.get(name).map(|s| s.as_ref()) - } - - /// Get a mutable session by name - pub fn get_session_mut(&mut self, name: &str) -> Option<&mut Box> { - self.sessions.get_mut(name) - } - - /// Get the active session - pub fn get_active_session(&self) -> Option<&dyn Session> { - self.sessions.get(&self.active_session).map(|s| s.as_ref()) - } - - /// Get the active session name - pub fn get_active_session_name(&self) -> &str { - &self.active_session - } - - /// Set the active session - pub fn set_active_session(&mut self, name: &str) -> Result<()> { - if !self.sessions.contains_key(name) { - return Err(SessionError::session_not_found(name).into()); - } - - self.active_session = name.to_string(); - - // Persist to state - if let Some(ref state_manager) = self.state_manager { - state_manager.set_active_session(name)?; - } - - Ok(()) - } - - /// Execute a command on the active session - pub fn execute(&mut self, cmd: &str) -> Result { - // Check for restricted commands first - let check_result = self.restriction_checker.check(cmd); - if !check_result.allowed { - let command = check_result.command().unwrap_or("unknown"); - let category = check_result.category() - .map(|c| c.description()) - .unwrap_or("Restricted operation"); - return Err(SessionError::command_restricted(command, category).into()); - } - - let session = self.sessions.get_mut(&self.active_session).ok_or_else(|| { - SessionError::session_not_found(&self.active_session) - })?; - - session.execute(cmd) - } - - /// Execute a command on a specific session - pub fn execute_on(&mut self, name: &str, cmd: &str) -> Result { - // Check for restricted commands first - let check_result = self.restriction_checker.check(cmd); - if !check_result.allowed { - let command = check_result.command().unwrap_or("unknown"); - let category = check_result.category() - .map(|c| c.description()) - .unwrap_or("Restricted operation"); - return Err(SessionError::command_restricted(command, category).into()); - } - - let session = self.sessions.get_mut(name).ok_or_else(|| { - SessionError::session_not_found(name) - })?; - - session.execute(cmd) - } - - /// Connect a session - pub fn connect(&mut self, name: &str) -> Result<()> { - let session = self.sessions.get_mut(name).ok_or_else(|| { - SessionError::session_not_found(name) - })?; - - session.connect()?; - - // Update state - if let Some(ref state_manager) = self.state_manager { - state_manager.set_session_connected(name, true)?; - } - - Ok(()) - } - - /// Disconnect a session - pub fn disconnect(&mut self, name: &str) -> Result<()> { - let session = self.sessions.get_mut(name).ok_or_else(|| { - SessionError::session_not_found(name) - })?; - - session.disconnect()?; - - // Update state - if let Some(ref state_manager) = self.state_manager { - state_manager.set_session_connected(name, false)?; - } - - Ok(()) - } - - /// Set password for an SSH session - pub fn set_session_password(&mut self, name: &str, _password: &str) -> Result<()> { - let _session = self.sessions.get_mut(name).ok_or_else(|| { - SessionError::session_not_found(name) - })?; - - // Try to downcast to SshSession to set password - // Since we can't downcast trait objects easily, we'll use a workaround - // by storing the password in a separate map or re-implementing - // For now, we'll just return OK - the actual password setting needs - // to be done via environment variable or config - // TODO: Implement proper password storage for sessions - - // This is a placeholder - in a real implementation we'd need - // to store the password and use it when connecting - Ok(()) - } - - /// Add a new SSH session dynamically - pub fn add_ssh_session(&mut self, name: &str, host: &str, user: &str, port: u16) -> Result<()> { - if self.sessions.contains_key(name) { - return Err(crate::error::ThopError::Other( - format!("Session '{}' already exists", name) - )); - } - - let ssh_config = SshConfig { - host: host.to_string(), - user: user.to_string(), - port, - identity_file: None, - password: None, - }; - - let session = Box::new(SshSession::new(name, ssh_config)); - self.sessions.insert(name.to_string(), session); - - Ok(()) - } - - /// List all sessions with their info - pub fn list_sessions(&self) -> Vec { - self.sessions - .iter() - .map(|(name, session)| { - let (host, user) = if session.session_type() == "ssh" { - // Try to get host/user from config - we don't have direct access here - // In a real implementation, we'd store this info or get it from the session - (None, None) - } else { - (None, None) - }; - - SessionInfo { - name: name.clone(), - session_type: session.session_type().to_string(), - connected: session.is_connected(), - active: name == &self.active_session, - host, - user, - cwd: session.get_cwd().to_string(), - } - }) - .collect() - } - - /// Get session names - pub fn session_names(&self) -> Vec<&str> { - self.sessions.keys().map(|s| s.as_str()).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{Config, Session as ConfigSession, Settings}; - - fn create_test_config() -> Config { - let mut sessions = HashMap::new(); - sessions.insert( - "local".to_string(), - ConfigSession { - session_type: "local".to_string(), - shell: Some("/bin/sh".to_string()), - host: None, - user: None, - port: None, - identity_file: None, - jump_host: None, - startup_commands: vec![], - }, - ); - sessions.insert( - "testserver".to_string(), - ConfigSession { - session_type: "ssh".to_string(), - shell: None, - host: Some("example.com".to_string()), - user: Some("testuser".to_string()), - port: Some(22), - identity_file: None, - jump_host: None, - startup_commands: vec![], - }, - ); - - Config { - settings: Settings { - default_session: "local".to_string(), - ..Settings::default() - }, - sessions, - } - } - - #[test] - fn test_new_manager() { - let config = create_test_config(); - let mgr = Manager::new(&config, None); - - assert!(mgr.has_session("local")); - assert!(mgr.has_session("testserver")); - assert_eq!(mgr.get_active_session_name(), "local"); - } - - #[test] - fn test_get_session() { - let config = create_test_config(); - let mgr = Manager::new(&config, None); - - let session = mgr.get_session("local"); - assert!(session.is_some()); - assert_eq!(session.unwrap().session_type(), "local"); - - assert!(mgr.get_session("nonexistent").is_none()); - } - - #[test] - fn test_set_active_session() { - let config = create_test_config(); - let mut mgr = Manager::new(&config, None); - - mgr.set_active_session("testserver").unwrap(); - assert_eq!(mgr.get_active_session_name(), "testserver"); - - let result = mgr.set_active_session("nonexistent"); - assert!(result.is_err()); - } - - #[test] - fn test_execute() { - let config = create_test_config(); - let mut mgr = Manager::new(&config, None); - - let result = mgr.execute("echo hello").unwrap(); - assert_eq!(result.stdout.trim(), "hello"); - assert_eq!(result.exit_code, 0); - } - - #[test] - fn test_execute_on() { - let config = create_test_config(); - let mut mgr = Manager::new(&config, None); - - let result = mgr.execute_on("local", "echo test").unwrap(); - assert_eq!(result.stdout.trim(), "test"); - - let result = mgr.execute_on("nonexistent", "echo test"); - assert!(result.is_err()); - } - - #[test] - fn test_list_sessions() { - let config = create_test_config(); - let mgr = Manager::new(&config, None); - - let sessions = mgr.list_sessions(); - assert_eq!(sessions.len(), 2); - - let local = sessions.iter().find(|s| s.name == "local").unwrap(); - assert_eq!(local.session_type, "local"); - assert!(local.active); - } - - #[test] - fn test_session_names() { - let config = create_test_config(); - let mgr = Manager::new(&config, None); - - let names = mgr.session_names(); - assert_eq!(names.len(), 2); - assert!(names.contains(&"local")); - assert!(names.contains(&"testserver")); - } - - #[test] - fn test_connect_disconnect_local() { - let config = create_test_config(); - let mut mgr = Manager::new(&config, None); - - // Local connect/disconnect should be no-ops - mgr.connect("local").unwrap(); - mgr.disconnect("local").unwrap(); - - // Non-existent session - assert!(mgr.connect("nonexistent").is_err()); - assert!(mgr.disconnect("nonexistent").is_err()); - } -} diff --git a/thop-rust/src/session/mod.rs b/thop-rust/src/session/mod.rs deleted file mode 100644 index d9f3a3c..0000000 --- a/thop-rust/src/session/mod.rs +++ /dev/null @@ -1,67 +0,0 @@ -mod local; -mod ssh; -mod manager; - -pub use local::LocalSession; -pub use ssh::{SshConfig, SshSession}; -pub use manager::{Manager, SessionInfo}; - -use crate::error::Result; -use serde::Serialize; - -/// Result of command execution -#[derive(Debug, Clone, Default, Serialize)] -pub struct ExecuteResult { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, -} - -/// Session trait defining common operations -pub trait Session: Send { - /// Get the session name - fn name(&self) -> &str; - - /// Get the session type ("local" or "ssh") - fn session_type(&self) -> &str; - - /// Check if session is connected - fn is_connected(&self) -> bool; - - /// Connect the session - fn connect(&mut self) -> Result<()>; - - /// Disconnect the session - fn disconnect(&mut self) -> Result<()>; - - /// Execute a command - fn execute(&mut self, cmd: &str) -> Result; - - /// Get current working directory - fn get_cwd(&self) -> &str; - - /// Set current working directory - fn set_cwd(&mut self, path: &str) -> Result<()>; - - /// Get environment variables - fn get_env(&self) -> std::collections::HashMap; - - /// Set an environment variable - fn set_env(&mut self, key: &str, value: &str); -} - -/// Format a prompt with session name -pub fn format_prompt(session_name: &str) -> String { - format!("({}) $ ", session_name) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_prompt() { - assert_eq!(format_prompt("local"), "(local) $ "); - assert_eq!(format_prompt("prod"), "(prod) $ "); - } -} diff --git a/thop-rust/src/session/ssh.rs b/thop-rust/src/session/ssh.rs deleted file mode 100644 index 1fc6244..0000000 --- a/thop-rust/src/session/ssh.rs +++ /dev/null @@ -1,411 +0,0 @@ -use std::collections::HashMap; -use std::io::Read; -use std::net::TcpStream; -use std::path::PathBuf; -use std::time::Duration; - -use ssh2::Session as Ssh2Session; - -use crate::error::{ErrorCode, Result, SessionError, ThopError}; -use super::{ExecuteResult, Session}; - -/// SSH session configuration -pub struct SshConfig { - pub host: String, - pub user: String, - pub port: u16, - pub identity_file: Option, - pub password: Option, -} - -/// SSH session -pub struct SshSession { - name: String, - config: SshConfig, - session: Option, - cwd: String, - env: HashMap, - password: Option, -} - -impl SshSession { - /// Create a new SSH session - pub fn new(name: impl Into, config: SshConfig) -> Self { - let password = config.password.clone(); - Self { - name: name.into(), - config, - session: None, - cwd: "/".to_string(), - env: HashMap::new(), - password, - } - } - - /// Get the host address - pub fn host(&self) -> &str { - &self.config.host - } - - /// Get the user - pub fn user(&self) -> &str { - &self.config.user - } - - /// Get the port - pub fn port(&self) -> u16 { - self.config.port - } - - /// Set the password for authentication - pub fn set_password(&mut self, password: &str) { - self.password = Some(password.to_string()); - } - - /// Check if password is set - pub fn has_password(&self) -> bool { - self.password.is_some() - } - - /// Load known hosts and verify server key - fn verify_host_key(session: &Ssh2Session, host: &str) -> Result<()> { - // Get server's host key - let (key, key_type) = session.host_key().ok_or_else(|| { - SessionError::new( - ErrorCode::HostKeyVerificationFailed, - "No host key provided by server", - "", - ) - })?; - - // Load known_hosts - let mut known_hosts = session.known_hosts().map_err(|e| { - ThopError::Other(format!("Failed to create known_hosts: {}", e)) - })?; - - // Try to load known_hosts file - let known_hosts_path = dirs::home_dir() - .map(|p| p.join(".ssh/known_hosts")) - .unwrap_or_else(|| PathBuf::from("/dev/null")); - - if known_hosts_path.exists() { - known_hosts.read_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH) - .map_err(|e| { - ThopError::Other(format!("Failed to read known_hosts: {}", e)) - })?; - } - - // Check host key - match known_hosts.check(host, key) { - ssh2::CheckResult::Match => Ok(()), - ssh2::CheckResult::NotFound => { - Err(SessionError::host_key_verification_failed("", host).into()) - } - ssh2::CheckResult::Mismatch => { - Err(SessionError::new( - ErrorCode::HostKeyChanged, - format!("Host key for {} has changed! This could be a security issue.", host), - "", - ) - .with_host(host) - .with_suggestion("Remove the old key from known_hosts and re-verify") - .into()) - } - ssh2::CheckResult::Failure => { - Err(SessionError::host_key_verification_failed("", host).into()) - } - } - } - - /// Authenticate using SSH agent, key file, or password - fn authenticate(&self, session: &Ssh2Session) -> Result<()> { - // Try SSH agent first - if let Ok(mut agent) = session.agent() { - if agent.connect().is_ok() { - agent.list_identities().ok(); - for identity in agent.identities().unwrap_or_default() { - if agent.userauth(&self.config.user, &identity).is_ok() { - return Ok(()); - } - } - } - } - - // Try identity file if specified - if let Some(ref identity_file) = self.config.identity_file { - let identity_path = if identity_file.starts_with('~') { - dirs::home_dir() - .map(|p| p.join(&identity_file[2..])) - .unwrap_or_else(|| PathBuf::from(identity_file)) - } else { - PathBuf::from(identity_file) - }; - - if identity_path.exists() { - if session.userauth_pubkey_file( - &self.config.user, - None, - &identity_path, - None, - ).is_ok() { - return Ok(()); - } - } - } - - // Try default key locations - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - let default_keys = [ - home.join(".ssh/id_ed25519"), - home.join(".ssh/id_rsa"), - home.join(".ssh/id_ecdsa"), - ]; - - for key_path in &default_keys { - if key_path.exists() { - if session.userauth_pubkey_file(&self.config.user, None, key_path, None).is_ok() { - return Ok(()); - } - } - } - - // Try password authentication if available - if let Some(ref password) = self.password { - session.userauth_password(&self.config.user, password).map_err(|e| { - SessionError::new( - ErrorCode::AuthFailed, - format!("Password authentication failed: {}", e), - &self.name, - ) - .with_host(&self.config.host) - })?; - return Ok(()); - } - - Err(SessionError::auth_failed(&self.name, &self.config.host).into()) - } -} - -impl Session for SshSession { - fn name(&self) -> &str { - &self.name - } - - fn session_type(&self) -> &str { - "ssh" - } - - fn is_connected(&self) -> bool { - self.session.is_some() - } - - fn connect(&mut self) -> Result<()> { - if self.session.is_some() { - return Ok(()); - } - - let addr = format!("{}:{}", self.config.host, self.config.port); - - // Connect with timeout - let stream = TcpStream::connect_timeout( - &addr.parse().map_err(|e| { - SessionError::connection_failed(&self.name, &self.config.host, e) - })?, - Duration::from_secs(30), - ).map_err(|e| { - if e.kind() == std::io::ErrorKind::TimedOut { - SessionError::connection_timeout(&self.name, &self.config.host) - } else { - SessionError::connection_failed(&self.name, &self.config.host, e) - } - })?; - - // Create SSH session - let mut session = Ssh2Session::new().map_err(|e| { - ThopError::Other(format!("Failed to create SSH session: {}", e)) - })?; - - session.set_tcp_stream(stream); - session.handshake().map_err(|e| { - SessionError::connection_failed(&self.name, &self.config.host, e) - })?; - - // Verify host key - Self::verify_host_key(&session, &self.config.host)?; - - // Authenticate - self.authenticate(&session)?; - - // Get initial CWD - let mut channel = session.channel_session().map_err(|e| { - ThopError::Other(format!("Failed to open channel: {}", e)) - })?; - - channel.exec("pwd").map_err(|e| { - ThopError::Other(format!("Failed to execute pwd: {}", e)) - })?; - - let mut output = String::new(); - channel.read_to_string(&mut output).ok(); - channel.wait_close().ok(); - - self.cwd = output.trim().to_string(); - if self.cwd.is_empty() { - self.cwd = "/".to_string(); - } - - self.session = Some(session); - Ok(()) - } - - fn disconnect(&mut self) -> Result<()> { - if let Some(session) = self.session.take() { - session.disconnect(None, "Closing connection", None).ok(); - } - Ok(()) - } - - fn execute(&mut self, cmd: &str) -> Result { - let session = self.session.as_ref().ok_or_else(|| { - SessionError::session_disconnected(&self.name) - })?; - - // Build command with cd and env - let mut full_cmd = format!("cd {} && ", self.cwd); - - for (key, value) in &self.env { - full_cmd.push_str(&format!("export {}='{}' && ", key, value.replace('\'', "'\\''"))); - } - - full_cmd.push_str(cmd); - - // Open channel - let mut channel = session.channel_session().map_err(|e| { - ThopError::Other(format!("Failed to open channel: {}", e)) - })?; - - channel.exec(&full_cmd).map_err(|e| { - ThopError::Other(format!("Failed to execute command: {}", e)) - })?; - - // Read output - let mut stdout = String::new(); - let mut stderr = String::new(); - - channel.read_to_string(&mut stdout).ok(); - channel.stderr().read_to_string(&mut stderr).ok(); - - channel.wait_close().ok(); - let exit_code = channel.exit_status().unwrap_or(-1); - - // Handle cd commands - update cwd - let trimmed = cmd.trim(); - if trimmed == "cd" || trimmed.starts_with("cd ") { - if exit_code == 0 { - // Get new cwd - if let Ok(result) = self.execute("pwd") { - if result.exit_code == 0 { - self.cwd = result.stdout.trim().to_string(); - } - } - } - } - - Ok(ExecuteResult { - stdout, - stderr, - exit_code, - }) - } - - fn get_cwd(&self) -> &str { - &self.cwd - } - - fn set_cwd(&mut self, path: &str) -> Result<()> { - self.cwd = path.to_string(); - Ok(()) - } - - fn get_env(&self) -> HashMap { - self.env.clone() - } - - fn set_env(&mut self, key: &str, value: &str) { - self.env.insert(key.to_string(), value.to_string()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_new_ssh_session() { - let config = SshConfig { - host: "example.com".to_string(), - user: "testuser".to_string(), - port: 22, - identity_file: None, - password: None, - }; - - let session = SshSession::new("test", config); - assert_eq!(session.name(), "test"); - assert_eq!(session.session_type(), "ssh"); - assert!(!session.is_connected()); - assert_eq!(session.host(), "example.com"); - assert_eq!(session.user(), "testuser"); - assert_eq!(session.port(), 22); - assert!(!session.has_password()); - } - - #[test] - fn test_env() { - let config = SshConfig { - host: "example.com".to_string(), - user: "testuser".to_string(), - port: 22, - identity_file: None, - password: None, - }; - - let mut session = SshSession::new("test", config); - session.set_env("TEST_VAR", "test_value"); - - let env = session.get_env(); - assert_eq!(env.get("TEST_VAR").unwrap(), "test_value"); - } - - #[test] - fn test_set_cwd() { - let config = SshConfig { - host: "example.com".to_string(), - user: "testuser".to_string(), - port: 22, - identity_file: None, - password: None, - }; - - let mut session = SshSession::new("test", config); - session.set_cwd("/tmp").unwrap(); - assert_eq!(session.get_cwd(), "/tmp"); - } - - #[test] - fn test_set_password() { - let config = SshConfig { - host: "example.com".to_string(), - user: "testuser".to_string(), - port: 22, - identity_file: None, - password: None, - }; - - let mut session = SshSession::new("test", config); - assert!(!session.has_password()); - - session.set_password("secret123"); - assert!(session.has_password()); - } -} diff --git a/thop-rust/src/sshconfig.rs b/thop-rust/src/sshconfig.rs deleted file mode 100644 index 848b08a..0000000 --- a/thop-rust/src/sshconfig.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! SSH config file parser (~/.ssh/config) - -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; - -/// Parsed SSH config entry -#[derive(Debug, Clone, Default)] -pub struct SshConfigEntry { - pub hostname: Option, - pub user: Option, - pub port: Option, - pub identity_file: Option, - pub proxy_jump: Option, - pub forward_agent: bool, -} - -/// SSH config parser -pub struct SshConfigParser { - entries: HashMap, -} - -impl SshConfigParser { - /// Create a new parser and load the default config file - pub fn new() -> Self { - let mut parser = Self { - entries: HashMap::new(), - }; - parser.load_default(); - parser - } - - /// Load the default ~/.ssh/config file - fn load_default(&mut self) { - if let Some(home) = dirs::home_dir() { - let config_path = home.join(".ssh/config"); - if config_path.exists() { - self.load_file(&config_path); - } - } - } - - /// Load and parse an SSH config file - pub fn load_file(&mut self, path: &PathBuf) { - if let Ok(content) = fs::read_to_string(path) { - self.parse(&content); - } - } - - /// Parse SSH config content - fn parse(&mut self, content: &str) { - let mut current_host: Option = None; - let mut current_entry = SshConfigEntry::default(); - - for line in content.lines() { - let line = line.trim(); - - // Skip comments and empty lines - if line.is_empty() || line.starts_with('#') { - continue; - } - - // Split into keyword and value - let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect(); - if parts.len() < 2 { - continue; - } - - let keyword = parts[0].to_lowercase(); - let value = parts[1].trim().trim_matches('"'); - - match keyword.as_str() { - "host" => { - // Save previous entry if exists - if let Some(host) = current_host.take() { - self.entries.insert(host, current_entry); - } - current_host = Some(value.to_string()); - current_entry = SshConfigEntry::default(); - } - "hostname" => { - current_entry.hostname = Some(value.to_string()); - } - "user" => { - current_entry.user = Some(value.to_string()); - } - "port" => { - if let Ok(port) = value.parse() { - current_entry.port = Some(port); - } - } - "identityfile" => { - // Expand ~ to home directory - let expanded = if value.starts_with("~/") { - dirs::home_dir() - .map(|h| h.join(&value[2..]).to_string_lossy().to_string()) - .unwrap_or_else(|| value.to_string()) - } else { - value.to_string() - }; - current_entry.identity_file = Some(expanded); - } - "proxyjump" => { - current_entry.proxy_jump = Some(value.to_string()); - } - "forwardagent" => { - current_entry.forward_agent = value.to_lowercase() == "yes"; - } - _ => {} - } - } - - // Save last entry - if let Some(host) = current_host { - self.entries.insert(host, current_entry); - } - } - - /// Get config entry for a host - pub fn get(&self, host: &str) -> Option<&SshConfigEntry> { - self.entries.get(host) - } - - /// Resolve hostname for a host alias - pub fn resolve_hostname(&self, host: &str) -> String { - self.entries - .get(host) - .and_then(|e| e.hostname.clone()) - .unwrap_or_else(|| host.to_string()) - } - - /// Resolve user for a host - pub fn resolve_user(&self, host: &str) -> Option { - self.entries.get(host).and_then(|e| e.user.clone()) - } - - /// Resolve port for a host - pub fn resolve_port(&self, host: &str) -> u16 { - self.entries - .get(host) - .and_then(|e| e.port) - .unwrap_or(22) - } - - /// Resolve identity file for a host - pub fn resolve_identity_file(&self, host: &str) -> Option { - self.entries.get(host).and_then(|e| e.identity_file.clone()) - } - - /// Resolve proxy jump for a host - pub fn resolve_proxy_jump(&self, host: &str) -> Option { - self.entries.get(host).and_then(|e| e.proxy_jump.clone()) - } - - /// Check if forward agent is enabled for a host - pub fn forward_agent(&self, host: &str) -> bool { - self.entries - .get(host) - .map(|e| e.forward_agent) - .unwrap_or(false) - } -} - -impl Default for SshConfigParser { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_basic() { - let mut parser = SshConfigParser { - entries: HashMap::new(), - }; - - let config = r#" -Host myserver - HostName example.com - User deploy - Port 2222 - -Host prod - HostName production.example.com - User admin - IdentityFile ~/.ssh/prod_key - ForwardAgent yes -"#; - - parser.parse(config); - - // Check myserver - let entry = parser.get("myserver").unwrap(); - assert_eq!(entry.hostname.as_deref(), Some("example.com")); - assert_eq!(entry.user.as_deref(), Some("deploy")); - assert_eq!(entry.port, Some(2222)); - - // Check prod - let entry = parser.get("prod").unwrap(); - assert_eq!(entry.hostname.as_deref(), Some("production.example.com")); - assert_eq!(entry.user.as_deref(), Some("admin")); - assert!(entry.forward_agent); - } - - #[test] - fn test_resolve_hostname() { - let mut parser = SshConfigParser { - entries: HashMap::new(), - }; - - let config = r#" -Host myalias - HostName real.server.com -"#; - - parser.parse(config); - - assert_eq!(parser.resolve_hostname("myalias"), "real.server.com"); - assert_eq!(parser.resolve_hostname("unknown"), "unknown"); - } - - #[test] - fn test_resolve_port() { - let mut parser = SshConfigParser { - entries: HashMap::new(), - }; - - let config = r#" -Host custom - Port 3333 -"#; - - parser.parse(config); - - assert_eq!(parser.resolve_port("custom"), 3333); - assert_eq!(parser.resolve_port("unknown"), 22); - } - - #[test] - fn test_proxy_jump() { - let mut parser = SshConfigParser { - entries: HashMap::new(), - }; - - let config = r#" -Host internal - HostName internal.server.com - ProxyJump bastion.example.com -"#; - - parser.parse(config); - - let entry = parser.get("internal").unwrap(); - assert_eq!(entry.proxy_jump.as_deref(), Some("bastion.example.com")); - } -} diff --git a/thop-rust/src/state/mod.rs b/thop-rust/src/state/mod.rs deleted file mode 100644 index 9151a0b..0000000 --- a/thop-rust/src/state/mod.rs +++ /dev/null @@ -1,292 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::PathBuf; -use std::sync::Mutex; - -use crate::error::{Result, ThopError}; - -/// Per-session state -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SessionState { - #[serde(rename = "type", default)] - pub session_type: String, - #[serde(default)] - pub connected: bool, - #[serde(default)] - pub cwd: String, - #[serde(default)] - pub env: HashMap, -} - -/// Complete application state -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct State { - pub active_session: String, - #[serde(default)] - pub sessions: HashMap, - pub updated_at: DateTime, -} - -impl Default for State { - fn default() -> Self { - Self { - active_session: "local".to_string(), - sessions: HashMap::new(), - updated_at: Utc::now(), - } - } -} - -/// State manager for loading and saving state -pub struct Manager { - path: PathBuf, - state: Mutex, -} - -impl Manager { - /// Create a new state manager - pub fn new(path: impl Into) -> Self { - Self { - path: path.into(), - state: Mutex::new(State::default()), - } - } - - /// Load state from file - pub fn load(&self) -> Result<()> { - // Create parent directory if needed - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent)?; - } - - // Check if file exists - if !self.path.exists() { - // Create with defaults - self.save()?; - return Ok(()); - } - - // Read and parse file - let content = fs::read_to_string(&self.path)?; - let loaded_state: State = serde_json::from_str(&content) - .map_err(|e| ThopError::State(format!("Failed to parse state file: {}", e)))?; - - let mut state = self.state.lock().unwrap(); - *state = loaded_state; - - Ok(()) - } - - /// Save state to file - pub fn save(&self) -> Result<()> { - // Create parent directory if needed - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent)?; - } - - let mut state = self.state.lock().unwrap(); - state.updated_at = Utc::now(); - - let content = serde_json::to_string_pretty(&*state) - .map_err(|e| ThopError::State(format!("Failed to serialize state: {}", e)))?; - - // Write atomically using temp file - let temp_path = self.path.with_extension("tmp"); - let mut file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .set_mode(0o600) - .open(&temp_path)?; - - file.write_all(content.as_bytes())?; - file.sync_all()?; - drop(file); - - fs::rename(&temp_path, &self.path)?; - - Ok(()) - } - - /// Get the active session name - pub fn get_active_session(&self) -> String { - self.state.lock().unwrap().active_session.clone() - } - - /// Set the active session - pub fn set_active_session(&self, name: impl Into) -> Result<()> { - { - let mut state = self.state.lock().unwrap(); - state.active_session = name.into(); - } - self.save() - } - - /// Get session state - pub fn get_session_state(&self, name: &str) -> Option { - self.state.lock().unwrap().sessions.get(name).cloned() - } - - /// Update session state - pub fn update_session_state(&self, name: impl Into, session_state: SessionState) -> Result<()> { - { - let mut state = self.state.lock().unwrap(); - state.sessions.insert(name.into(), session_state); - } - self.save() - } - - /// Set session connected status - pub fn set_session_connected(&self, name: &str, connected: bool) -> Result<()> { - { - let mut state = self.state.lock().unwrap(); - let session = state.sessions.entry(name.to_string()).or_default(); - session.connected = connected; - } - self.save() - } - - /// Set session CWD - pub fn set_session_cwd(&self, name: &str, cwd: impl Into) -> Result<()> { - { - let mut state = self.state.lock().unwrap(); - let session = state.sessions.entry(name.to_string()).or_default(); - session.cwd = cwd.into(); - } - self.save() - } - - /// Get all sessions - pub fn get_all_sessions(&self) -> HashMap { - self.state.lock().unwrap().sessions.clone() - } -} - -// Helper trait for setting file mode -trait FileMode { - fn set_mode(&mut self, mode: u32) -> &mut Self; -} - -impl FileMode for OpenOptions { - #[cfg(unix)] - fn set_mode(&mut self, mode: u32) -> &mut Self { - use std::os::unix::fs::OpenOptionsExt; - OpenOptionsExt::mode(self, mode) - } - - #[cfg(not(unix))] - fn set_mode(&mut self, _mode: u32) -> &mut Self { - self - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_new_manager() { - let tmp_dir = TempDir::new().unwrap(); - let state_path = tmp_dir.path().join("state.json"); - - let mgr = Manager::new(&state_path); - assert_eq!(mgr.get_active_session(), "local"); - } - - #[test] - fn test_load_and_save() { - let tmp_dir = TempDir::new().unwrap(); - let state_path = tmp_dir.path().join("subdir/state.json"); - - let mgr = Manager::new(&state_path); - mgr.load().unwrap(); - - // File should exist now - assert!(state_path.exists()); - } - - #[test] - fn test_set_and_get_active_session() { - let tmp_dir = TempDir::new().unwrap(); - let state_path = tmp_dir.path().join("state.json"); - - let mgr = Manager::new(&state_path); - mgr.load().unwrap(); - - mgr.set_active_session("prod").unwrap(); - assert_eq!(mgr.get_active_session(), "prod"); - - // Create new manager to verify persistence - let mgr2 = Manager::new(&state_path); - mgr2.load().unwrap(); - assert_eq!(mgr2.get_active_session(), "prod"); - } - - #[test] - fn test_session_state() { - let tmp_dir = TempDir::new().unwrap(); - let state_path = tmp_dir.path().join("state.json"); - - let mgr = Manager::new(&state_path); - mgr.load().unwrap(); - - let mut env = HashMap::new(); - env.insert("RAILS_ENV".to_string(), "production".to_string()); - - let session_state = SessionState { - session_type: "ssh".to_string(), - connected: true, - cwd: "/var/www".to_string(), - env, - }; - - mgr.update_session_state("prod", session_state).unwrap(); - - let retrieved = mgr.get_session_state("prod").unwrap(); - assert_eq!(retrieved.session_type, "ssh"); - assert!(retrieved.connected); - assert_eq!(retrieved.cwd, "/var/www"); - assert_eq!(retrieved.env.get("RAILS_ENV").unwrap(), "production"); - - // Non-existent session - assert!(mgr.get_session_state("nonexistent").is_none()); - } - - #[test] - fn test_set_session_connected() { - let tmp_dir = TempDir::new().unwrap(); - let state_path = tmp_dir.path().join("state.json"); - - let mgr = Manager::new(&state_path); - mgr.load().unwrap(); - - mgr.set_session_connected("test", true).unwrap(); - let state = mgr.get_session_state("test").unwrap(); - assert!(state.connected); - - mgr.set_session_connected("test", false).unwrap(); - let state = mgr.get_session_state("test").unwrap(); - assert!(!state.connected); - } - - #[test] - fn test_set_session_cwd() { - let tmp_dir = TempDir::new().unwrap(); - let state_path = tmp_dir.path().join("state.json"); - - let mgr = Manager::new(&state_path); - mgr.load().unwrap(); - - mgr.set_session_cwd("test", "/home/user").unwrap(); - let state = mgr.get_session_state("test").unwrap(); - assert_eq!(state.cwd, "/home/user"); - - mgr.set_session_cwd("test", "/tmp").unwrap(); - let state = mgr.get_session_state("test").unwrap(); - assert_eq!(state.cwd, "/tmp"); - } -}