mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
Compare commits
4 Commits
ipuaro-v0.
...
ipuaro-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f947c6d157 | ||
|
|
33d52bc7ca | ||
|
|
2c6eb6ce9b | ||
|
|
7d18e87423 |
@@ -5,6 +5,188 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.15.0] - 2025-12-01 - CLI Entry Point
|
||||
|
||||
### Added
|
||||
|
||||
- **Onboarding Module (0.15.3)**
|
||||
- `checkRedis()`: Validates Redis connection with helpful error messages
|
||||
- `checkOllama()`: Validates Ollama availability with install instructions
|
||||
- `checkModel()`: Checks if LLM model is available, offers to pull if missing
|
||||
- `checkProjectSize()`: Warns if project has >10K files
|
||||
- `runOnboarding()`: Runs all pre-flight checks before starting
|
||||
|
||||
- **Start Command (0.15.1)**
|
||||
- Full TUI startup with dependency injection
|
||||
- Integrates onboarding checks before launch
|
||||
- Interactive model pull prompt if model missing
|
||||
- Redis, storage, LLM, and tools initialization
|
||||
- Clean shutdown with disconnect on exit
|
||||
|
||||
- **Init Command (0.15.1)**
|
||||
- Creates `.ipuaro.json` configuration file
|
||||
- Default template with Redis, LLM, and edit settings
|
||||
- `--force` option to overwrite existing config
|
||||
- Helpful output showing available options
|
||||
|
||||
- **Index Command (0.15.1)**
|
||||
- Standalone project indexing without TUI
|
||||
- File scanning with progress output
|
||||
- AST parsing with error handling
|
||||
- Metadata analysis and storage
|
||||
- Symbol index and dependency graph building
|
||||
- Duration and statistics reporting
|
||||
|
||||
- **CLI Options (0.15.2)**
|
||||
- `--auto-apply`: Enable auto-apply mode for edits
|
||||
- `--model <name>`: Override LLM model
|
||||
- `--help`: Show help
|
||||
- `--version`: Show version
|
||||
|
||||
- **Tools Setup Helper**
|
||||
- `registerAllTools()`: Registers all 18 tools with the registry
|
||||
- Clean separation from CLI logic
|
||||
|
||||
### Changed
|
||||
|
||||
- **CLI Architecture**
|
||||
- Refactored from placeholder to full implementation
|
||||
- Commands in separate modules under `src/cli/commands/`
|
||||
- Dynamic version from package.json
|
||||
- `start` command is now default (runs with `ipuaro` or `ipuaro start`)
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Total tests: 1372 (29 new CLI tests)
|
||||
- Coverage: ~98% maintained (CLI excluded from coverage thresholds)
|
||||
- New test files: onboarding.test.ts, init.test.ts, tools-setup.test.ts
|
||||
|
||||
---
|
||||
|
||||
## [0.14.0] - 2025-12-01 - Commands
|
||||
|
||||
### Added
|
||||
|
||||
- **useCommands Hook**
|
||||
- New hook for handling slash commands in TUI
|
||||
- `parseCommand()`: Parses command input into name and arguments
|
||||
- `isCommand()`: Checks if input is a slash command
|
||||
- `executeCommand()`: Executes command and returns result
|
||||
- `getCommands()`: Returns all available command definitions
|
||||
|
||||
- **8 Slash Commands**
|
||||
- `/help` - Shows all commands and hotkeys
|
||||
- `/clear` - Clears chat history (keeps session)
|
||||
- `/undo` - Reverts last file change from undo stack
|
||||
- `/sessions [list|load|delete] [id]` - Manage sessions
|
||||
- `/status` - Shows system status (LLM, context, stats)
|
||||
- `/reindex` - Forces full project reindexation
|
||||
- `/eval` - LLM self-check for hallucinations
|
||||
- `/auto-apply [on|off]` - Toggle auto-apply mode
|
||||
|
||||
- **Command Result Display**
|
||||
- Visual feedback box for command results
|
||||
- Green border for success, red for errors
|
||||
- Auto-clear after 5 seconds
|
||||
|
||||
### Changed
|
||||
|
||||
- **App.tsx Integration**
|
||||
- Added `useCommands` hook integration
|
||||
- Command handling in `handleSubmit`
|
||||
- New state for `autoApply` and `commandResult`
|
||||
- Reindex placeholder action
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Total tests: 1343 (38 new useCommands tests)
|
||||
- Test coverage: ~98% maintained
|
||||
- Modular command factory functions for maintainability
|
||||
- Commands extracted to separate functions to stay under line limits
|
||||
|
||||
---
|
||||
|
||||
## [0.13.0] - 2025-12-01 - Security
|
||||
|
||||
### Added
|
||||
|
||||
- **PathValidator Utility (0.13.3)**
|
||||
- Centralized path validation for all file operations
|
||||
- Prevents path traversal attacks (`..`, `~`)
|
||||
- Validates paths are within project root
|
||||
- Sync (`validateSync`) and async (`validate`) validation methods
|
||||
- Quick check method (`isWithin`) for simple validations
|
||||
- Resolution methods (`resolve`, `relativize`, `resolveOrThrow`)
|
||||
- Detailed validation results with status and reason
|
||||
- Options for file existence, directory/file type checks
|
||||
|
||||
- **Security Module**
|
||||
- New `infrastructure/security` module
|
||||
- Exports: `PathValidator`, `createPathValidator`, `validatePath`
|
||||
- Type exports: `PathValidationResult`, `PathValidationStatus`, `PathValidatorOptions`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Refactored All File Tools to Use PathValidator**
|
||||
- GetLinesTool: Uses PathValidator for path validation
|
||||
- GetFunctionTool: Uses PathValidator for path validation
|
||||
- GetClassTool: Uses PathValidator for path validation
|
||||
- GetStructureTool: Uses PathValidator for path validation
|
||||
- EditLinesTool: Uses PathValidator for path validation
|
||||
- CreateFileTool: Uses PathValidator for path validation
|
||||
- DeleteFileTool: Uses PathValidator for path validation
|
||||
|
||||
- **Improved Error Messages**
|
||||
- More specific error messages from PathValidator
|
||||
- "Path contains traversal patterns" for `..` attempts
|
||||
- "Path is outside project root" for absolute paths outside project
|
||||
- "Path is empty" for empty/whitespace paths
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Total tests: 1305 (51 new PathValidator tests)
|
||||
- Test coverage: ~98% maintained
|
||||
- No breaking changes to existing tool APIs
|
||||
- Security validation is now consistent across all 7 file tools
|
||||
|
||||
---
|
||||
|
||||
## [0.12.0] - 2025-12-01 - TUI Advanced
|
||||
|
||||
### Added
|
||||
|
||||
- **DiffView Component (0.12.1)**
|
||||
- Inline diff display with green (added) and red (removed) highlighting
|
||||
- Header with file path and line range: `┌─── path (lines X-Y) ───┐`
|
||||
- Line numbers with proper padding
|
||||
- Stats footer showing additions and deletions count
|
||||
|
||||
- **ConfirmDialog Component (0.12.2)**
|
||||
- Confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options
|
||||
- Optional diff preview integration
|
||||
- Keyboard input handling (Y/N/E keys, Escape)
|
||||
- Visual selection feedback
|
||||
|
||||
- **ErrorDialog Component (0.12.3)**
|
||||
- Error dialog with [R] Retry / [S] Skip / [A] Abort options
|
||||
- Recoverable vs non-recoverable error handling
|
||||
- Disabled buttons for non-recoverable errors
|
||||
- Keyboard input with Escape support
|
||||
|
||||
- **Progress Component (0.12.4)**
|
||||
- Progress bar display: `[=====> ] 45% (120/267 files)`
|
||||
- Color-coded progress (cyan < 50%, yellow < 100%, green = 100%)
|
||||
- Configurable width
|
||||
- Label support for context
|
||||
|
||||
### Changed
|
||||
|
||||
- Total tests: 1254 (unchanged - TUI components excluded from coverage)
|
||||
- TUI layer now has 8 components + 2 hooks
|
||||
- All v0.12.0 roadmap items complete
|
||||
|
||||
---
|
||||
|
||||
## [0.11.0] - 2025-12-01 - TUI Basic
|
||||
|
||||
### Added
|
||||
|
||||
@@ -148,9 +148,10 @@ packages/ipuaro/
|
||||
|
||||
---
|
||||
|
||||
## Version 0.1.0 - Foundation ⚙️
|
||||
## Version 0.1.0 - Foundation ⚙️ ✅
|
||||
|
||||
**Priority:** CRITICAL
|
||||
**Status:** Complete (v0.1.0 released)
|
||||
|
||||
### 0.1.1 - Project Setup
|
||||
|
||||
@@ -310,9 +311,10 @@ interface Config {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.2.0 - Redis Storage 🗄️
|
||||
## Version 0.2.0 - Redis Storage 🗄️ ✅
|
||||
|
||||
**Priority:** CRITICAL
|
||||
**Status:** Complete (v0.2.0 released)
|
||||
|
||||
### 0.2.1 - Redis Client
|
||||
|
||||
@@ -367,9 +369,10 @@ class RedisStorage implements IStorage {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.3.0 - Indexer 📂
|
||||
## Version 0.3.0 - Indexer 📂 ✅
|
||||
|
||||
**Priority:** CRITICAL
|
||||
**Status:** Complete (v0.3.0, v0.3.1 released)
|
||||
|
||||
### 0.3.1 - File Scanner
|
||||
|
||||
@@ -456,9 +459,10 @@ class Watchdog {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.4.0 - LLM Integration 🤖
|
||||
## Version 0.4.0 - LLM Integration 🤖 ✅
|
||||
|
||||
**Priority:** CRITICAL
|
||||
**Status:** Complete (v0.4.0 released)
|
||||
|
||||
### 0.4.1 - Ollama Client
|
||||
|
||||
@@ -531,9 +535,10 @@ function parseToolCalls(response: string): ToolCall[]
|
||||
|
||||
---
|
||||
|
||||
## Version 0.5.0 - Read Tools 📖
|
||||
## Version 0.5.0 - Read Tools 📖 ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.5.0 released)
|
||||
|
||||
4 tools for reading code without modification.
|
||||
|
||||
@@ -609,9 +614,10 @@ class GetStructureTool implements ITool {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.6.0 - Edit Tools ✏️
|
||||
## Version 0.6.0 - Edit Tools ✏️ ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.6.0 released)
|
||||
|
||||
3 tools for file modifications. All require confirmation (unless autoApply).
|
||||
|
||||
@@ -662,9 +668,10 @@ class DeleteFileTool implements ITool {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.7.0 - Search Tools 🔍
|
||||
## Version 0.7.0 - Search Tools 🔍 ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.7.0 released)
|
||||
|
||||
### 0.7.1 - find_references
|
||||
|
||||
@@ -699,9 +706,10 @@ class FindDefinitionTool implements ITool {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.8.0 - Analysis Tools 📊
|
||||
## Version 0.8.0 - Analysis Tools 📊 ✅
|
||||
|
||||
**Priority:** MEDIUM
|
||||
**Status:** Complete (v0.8.0 released)
|
||||
|
||||
### 0.8.1 - get_dependencies
|
||||
|
||||
@@ -742,9 +750,10 @@ class FindDefinitionTool implements ITool {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.9.0 - Git & Run Tools 🚀
|
||||
## Version 0.9.0 - Git & Run Tools 🚀 ✅
|
||||
|
||||
**Priority:** MEDIUM
|
||||
**Status:** Complete (v0.9.0 released) — includes CommandSecurity (Blacklist/Whitelist)
|
||||
|
||||
### 0.9.1 - git_status
|
||||
|
||||
@@ -798,9 +807,10 @@ class FindDefinitionTool implements ITool {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.10.0 - Session Management 💾
|
||||
## Version 0.10.0 - Session Management 💾 ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.10.0 released) — includes HandleMessage orchestrator (originally planned for 0.14.0)
|
||||
|
||||
### 0.10.1 - Session Entity
|
||||
|
||||
@@ -873,9 +883,10 @@ class ContextManager {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.11.0 - TUI Basic 🖥️
|
||||
## Version 0.11.0 - TUI Basic 🖥️ ✅
|
||||
|
||||
**Priority:** CRITICAL
|
||||
**Status:** Complete (v0.11.0 released) — includes useHotkeys (originally planned for 0.16.0)
|
||||
|
||||
### 0.11.1 - App Shell
|
||||
|
||||
@@ -945,9 +956,10 @@ interface Props {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.12.0 - TUI Advanced 🎨
|
||||
## Version 0.12.0 - TUI Advanced 🎨 ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.12.0 released)
|
||||
|
||||
### 0.12.1 - DiffView
|
||||
|
||||
@@ -1009,9 +1021,10 @@ interface Props {
|
||||
|
||||
---
|
||||
|
||||
## Version 0.13.0 - Security 🔒
|
||||
## Version 0.13.0 - Security 🔒 ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.13.0 released) — Blacklist/Whitelist done in v0.9.0, PathValidator in v0.13.0
|
||||
|
||||
### 0.13.1 - Blacklist
|
||||
|
||||
@@ -1055,11 +1068,14 @@ function validatePath(path: string, projectRoot: string): boolean
|
||||
|
||||
---
|
||||
|
||||
## Version 0.14.0 - Orchestrator 🎭
|
||||
## [DONE] Original 0.14.0 - Orchestrator 🎭 ✅
|
||||
|
||||
**Priority:** CRITICAL
|
||||
> **Note:** This was implemented in v0.10.0 as part of Session Management
|
||||
|
||||
### 0.14.1 - HandleMessage Use Case
|
||||
<details>
|
||||
<summary>Originally planned (click to expand)</summary>
|
||||
|
||||
### HandleMessage Use Case (Done in v0.10.5)
|
||||
|
||||
```typescript
|
||||
// src/application/use-cases/HandleMessage.ts
|
||||
@@ -1091,7 +1107,7 @@ class HandleMessage {
|
||||
}
|
||||
```
|
||||
|
||||
### 0.14.2 - Edit Flow
|
||||
### Edit Flow (Done in v0.10.5)
|
||||
|
||||
```typescript
|
||||
// Edit handling inside HandleMessage:
|
||||
@@ -1104,17 +1120,49 @@ class HandleMessage {
|
||||
// - Update storage (lines, AST, meta)
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- [ ] Unit tests for HandleMessage
|
||||
- [ ] E2E tests for full message flow
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Version 0.15.0 - Commands 📝
|
||||
## [DONE] Original 0.16.0 - Hotkeys & Polish ⌨️ ✅
|
||||
|
||||
**Priority:** MEDIUM
|
||||
> **Note:** useHotkeys done in v0.11.0, ContextManager auto-compression in v0.10.3
|
||||
|
||||
7 slash commands for TUI.
|
||||
<details>
|
||||
<summary>Originally planned (click to expand)</summary>
|
||||
|
||||
### Hotkeys (Done in v0.11.0)
|
||||
|
||||
```typescript
|
||||
// src/tui/hooks/useHotkeys.ts
|
||||
|
||||
Ctrl+C // Interrupt generation (1st), exit (2nd)
|
||||
Ctrl+D // Exit with session save
|
||||
Ctrl+Z // Undo (= /undo)
|
||||
↑/↓ // Input history
|
||||
Tab // Path autocomplete
|
||||
```
|
||||
|
||||
### Auto-compression (Done in v0.10.3)
|
||||
|
||||
```typescript
|
||||
// Triggered at >80% context:
|
||||
// 1. LLM summarizes old messages
|
||||
// 2. Remove tool results older than 5 messages
|
||||
// 3. Update status bar (ctx% changes)
|
||||
// No modal notification - silent
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Version 0.14.0 - Commands 📝 ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.14.0 released)
|
||||
|
||||
8 slash commands for TUI.
|
||||
|
||||
```typescript
|
||||
// src/tui/hooks/useCommands.ts
|
||||
@@ -1130,47 +1178,16 @@ class HandleMessage {
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- [ ] Unit tests for command handlers
|
||||
- [x] Unit tests for command handlers (38 tests)
|
||||
|
||||
---
|
||||
|
||||
## Version 0.16.0 - Hotkeys & Polish ⌨️
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
### 0.16.1 - Hotkeys
|
||||
|
||||
```typescript
|
||||
// src/tui/hooks/useHotkeys.ts
|
||||
|
||||
Ctrl+C // Interrupt generation (1st), exit (2nd)
|
||||
Ctrl+D // Exit with session save
|
||||
Ctrl+Z // Undo (= /undo)
|
||||
↑/↓ // Input history
|
||||
Tab // Path autocomplete
|
||||
```
|
||||
|
||||
### 0.16.2 - Auto-compression
|
||||
|
||||
```typescript
|
||||
// Triggered at >80% context:
|
||||
// 1. LLM summarizes old messages
|
||||
// 2. Remove tool results older than 5 messages
|
||||
// 3. Update status bar (ctx% changes)
|
||||
// No modal notification - silent
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- [ ] Integration tests for hotkeys
|
||||
- [ ] Unit tests for compression
|
||||
|
||||
---
|
||||
|
||||
## Version 0.17.0 - CLI Entry Point 🚪
|
||||
## Version 0.15.0 - CLI Entry Point 🚪 ✅
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** Complete (v0.15.0 released)
|
||||
|
||||
### 0.17.1 - CLI Commands
|
||||
### 0.15.1 - CLI Commands
|
||||
|
||||
```typescript
|
||||
// src/cli/index.ts
|
||||
@@ -1180,7 +1197,7 @@ ipuaro init // Create .ipuaro.json config
|
||||
ipuaro index // Index only (no TUI)
|
||||
```
|
||||
|
||||
### 0.17.2 - CLI Options
|
||||
### 0.15.2 - CLI Options
|
||||
|
||||
```bash
|
||||
--auto-apply # Enable auto-apply mode
|
||||
@@ -1189,7 +1206,7 @@ ipuaro index // Index only (no TUI)
|
||||
--version # Show version
|
||||
```
|
||||
|
||||
### 0.17.3 - Onboarding
|
||||
### 0.15.3 - Onboarding
|
||||
|
||||
```typescript
|
||||
// src/cli/commands/start.ts
|
||||
@@ -1202,15 +1219,16 @@ ipuaro index // Index only (no TUI)
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- [ ] E2E tests for CLI
|
||||
- [x] Unit tests for CLI commands (29 tests)
|
||||
|
||||
---
|
||||
|
||||
## Version 0.18.0 - Error Handling ⚠️
|
||||
## Version 0.16.0 - Error Handling ⚠️ ⬜
|
||||
|
||||
**Priority:** HIGH
|
||||
**Status:** NEXT MILESTONE — IpuaroError exists (v0.1.0), need full error matrix implementation
|
||||
|
||||
### 0.18.1 - Error Types
|
||||
### 0.16.1 - Error Types
|
||||
|
||||
```typescript
|
||||
// src/shared/errors/IpuaroError.ts
|
||||
@@ -1223,7 +1241,7 @@ class IpuaroError extends Error {
|
||||
}
|
||||
```
|
||||
|
||||
### 0.18.2 - Error Handling Matrix
|
||||
### 0.16.2 - Error Handling Matrix
|
||||
|
||||
| Error | Recoverable | Options |
|
||||
|-------|-------------|---------|
|
||||
@@ -1244,16 +1262,16 @@ class IpuaroError extends Error {
|
||||
**Target:** Stable release
|
||||
|
||||
**Checklist:**
|
||||
- [ ] All 18 tools implemented and tested
|
||||
- [ ] TUI fully functional
|
||||
- [ ] Session persistence working
|
||||
- [ ] Error handling complete
|
||||
- [x] All 18 tools implemented and tested ✅ (v0.9.0)
|
||||
- [x] TUI fully functional ✅ (v0.11.0, v0.12.0)
|
||||
- [x] Session persistence working ✅ (v0.10.0)
|
||||
- [ ] Error handling complete (partial)
|
||||
- [ ] Performance optimized
|
||||
- [ ] Documentation complete
|
||||
- [ ] 80%+ test coverage
|
||||
- [ ] 0 ESLint errors
|
||||
- [x] 80%+ test coverage ✅ (~98%)
|
||||
- [x] 0 ESLint errors ✅
|
||||
- [ ] Examples working
|
||||
- [ ] CHANGELOG.md up to date
|
||||
- [x] CHANGELOG.md up to date ✅
|
||||
|
||||
---
|
||||
|
||||
@@ -1327,5 +1345,6 @@ sessions:list # List<session_id>
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-29
|
||||
**Target Version:** 1.0.0
|
||||
**Last Updated:** 2025-12-01
|
||||
**Target Version:** 1.0.0
|
||||
**Current Version:** 0.15.0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samiyev/ipuaro",
|
||||
"version": "0.11.0",
|
||||
"version": "0.15.0",
|
||||
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
250
packages/ipuaro/src/cli/commands/index-cmd.ts
Normal file
250
packages/ipuaro/src/cli/commands/index-cmd.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Index command implementation.
|
||||
* Indexes project without starting TUI.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
import { RedisClient } from "../../infrastructure/storage/RedisClient.js"
|
||||
import { RedisStorage } from "../../infrastructure/storage/RedisStorage.js"
|
||||
import { generateProjectName } from "../../infrastructure/storage/schema.js"
|
||||
import { FileScanner } from "../../infrastructure/indexer/FileScanner.js"
|
||||
import { ASTParser } from "../../infrastructure/indexer/ASTParser.js"
|
||||
import { MetaAnalyzer } from "../../infrastructure/indexer/MetaAnalyzer.js"
|
||||
import { IndexBuilder } from "../../infrastructure/indexer/IndexBuilder.js"
|
||||
import { createFileData } from "../../domain/value-objects/FileData.js"
|
||||
import type { FileAST } from "../../domain/value-objects/FileAST.js"
|
||||
import { type Config, DEFAULT_CONFIG } from "../../shared/constants/config.js"
|
||||
import { md5 } from "../../shared/utils/hash.js"
|
||||
import { checkRedis } from "./onboarding.js"
|
||||
|
||||
type Language = "ts" | "tsx" | "js" | "jsx"
|
||||
|
||||
/**
|
||||
* Result of index command.
|
||||
*/
|
||||
export interface IndexResult {
|
||||
success: boolean
|
||||
filesIndexed: number
|
||||
filesSkipped: number
|
||||
errors: string[]
|
||||
duration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress callback for indexing.
|
||||
*/
|
||||
export type IndexProgressCallback = (
|
||||
phase: "scanning" | "parsing" | "analyzing" | "storing",
|
||||
current: number,
|
||||
total: number,
|
||||
currentFile?: string,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Execute the index command.
|
||||
*/
|
||||
export async function executeIndex(
|
||||
projectPath: string,
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
onProgress?: IndexProgressCallback,
|
||||
): Promise<IndexResult> {
|
||||
const startTime = Date.now()
|
||||
const resolvedPath = path.resolve(projectPath)
|
||||
const projectName = generateProjectName(resolvedPath)
|
||||
const errors: string[] = []
|
||||
|
||||
console.warn(`📁 Indexing project: ${resolvedPath}`)
|
||||
console.warn(` Project name: ${projectName}\n`)
|
||||
|
||||
const redisResult = await checkRedis(config.redis)
|
||||
if (!redisResult.ok) {
|
||||
console.error(`❌ ${redisResult.error ?? "Redis unavailable"}`)
|
||||
return {
|
||||
success: false,
|
||||
filesIndexed: 0,
|
||||
filesSkipped: 0,
|
||||
errors: [redisResult.error ?? "Redis unavailable"],
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
}
|
||||
|
||||
let redisClient: RedisClient | null = null
|
||||
|
||||
try {
|
||||
redisClient = new RedisClient(config.redis)
|
||||
await redisClient.connect()
|
||||
|
||||
const storage = new RedisStorage(redisClient, projectName)
|
||||
const scanner = new FileScanner({
|
||||
onProgress: (progress): void => {
|
||||
onProgress?.("scanning", progress.current, progress.total, progress.currentFile)
|
||||
},
|
||||
})
|
||||
const astParser = new ASTParser()
|
||||
const metaAnalyzer = new MetaAnalyzer(resolvedPath)
|
||||
const indexBuilder = new IndexBuilder(resolvedPath)
|
||||
|
||||
console.warn("🔍 Scanning files...")
|
||||
const files = await scanner.scanAll(resolvedPath)
|
||||
console.warn(` Found ${String(files.length)} files\n`)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.warn("⚠️ No files found to index.")
|
||||
return {
|
||||
success: true,
|
||||
filesIndexed: 0,
|
||||
filesSkipped: 0,
|
||||
errors: [],
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
}
|
||||
|
||||
console.warn("📝 Parsing files...")
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
const fileContents = new Map<string, string>()
|
||||
let parsed = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(resolvedPath, file.path)
|
||||
const language = getLanguage(file.path)
|
||||
|
||||
if (!language) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, "utf-8")
|
||||
const ast = astParser.parse(content, language)
|
||||
|
||||
if (ast.parseError) {
|
||||
errors.push(
|
||||
`Parse error in ${file.path}: ${ast.parseErrorMessage ?? "unknown"}`,
|
||||
)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
allASTs.set(file.path, ast)
|
||||
fileContents.set(file.path, content)
|
||||
parsed++
|
||||
|
||||
onProgress?.("parsing", parsed + skipped, files.length, file.path)
|
||||
|
||||
if ((parsed + skipped) % 50 === 0) {
|
||||
process.stdout.write(
|
||||
`\r Parsed ${String(parsed)} files (${String(skipped)} skipped)...`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
errors.push(`Error reading ${file.path}: ${message}`)
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
console.warn(`\r Parsed ${String(parsed)} files (${String(skipped)} skipped) \n`)
|
||||
|
||||
console.warn("📊 Analyzing metadata...")
|
||||
let analyzed = 0
|
||||
for (const [filePath, ast] of allASTs) {
|
||||
const content = fileContents.get(filePath) ?? ""
|
||||
const meta = metaAnalyzer.analyze(
|
||||
path.join(resolvedPath, filePath),
|
||||
ast,
|
||||
content,
|
||||
allASTs,
|
||||
)
|
||||
|
||||
const fileData = createFileData({
|
||||
lines: content.split("\n"),
|
||||
hash: md5(content),
|
||||
size: content.length,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
|
||||
await storage.setFile(filePath, fileData)
|
||||
await storage.setAST(filePath, ast)
|
||||
await storage.setMeta(filePath, meta)
|
||||
|
||||
analyzed++
|
||||
onProgress?.("analyzing", analyzed, allASTs.size, filePath)
|
||||
|
||||
if (analyzed % 50 === 0) {
|
||||
process.stdout.write(
|
||||
`\r Analyzed ${String(analyzed)}/${String(allASTs.size)} files...`,
|
||||
)
|
||||
}
|
||||
}
|
||||
console.warn(`\r Analyzed ${String(analyzed)} files \n`)
|
||||
|
||||
console.warn("🏗️ Building indexes...")
|
||||
onProgress?.("storing", 0, 2)
|
||||
const symbolIndex = indexBuilder.buildSymbolIndex(allASTs)
|
||||
const depsGraph = indexBuilder.buildDepsGraph(allASTs)
|
||||
|
||||
await storage.setSymbolIndex(symbolIndex)
|
||||
await storage.setDepsGraph(depsGraph)
|
||||
onProgress?.("storing", 2, 2)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
const durationSec = (duration / 1000).toFixed(2)
|
||||
|
||||
console.warn(`✅ Indexing complete in ${durationSec}s`)
|
||||
console.warn(` Files indexed: ${String(parsed)}`)
|
||||
console.warn(` Files skipped: ${String(skipped)}`)
|
||||
console.warn(` Symbols: ${String(symbolIndex.size)}`)
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn(`\n⚠️ ${String(errors.length)} errors occurred:`)
|
||||
for (const error of errors.slice(0, 5)) {
|
||||
console.warn(` - ${error}`)
|
||||
}
|
||||
if (errors.length > 5) {
|
||||
console.warn(` ... and ${String(errors.length - 5)} more`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filesIndexed: parsed,
|
||||
filesSkipped: skipped,
|
||||
errors,
|
||||
duration,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`❌ Indexing failed: ${message}`)
|
||||
return {
|
||||
success: false,
|
||||
filesIndexed: 0,
|
||||
filesSkipped: 0,
|
||||
errors: [message],
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
} finally {
|
||||
if (redisClient) {
|
||||
await redisClient.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language from file extension.
|
||||
*/
|
||||
function getLanguage(filePath: string): Language | null {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".ts":
|
||||
return "ts"
|
||||
case ".tsx":
|
||||
return "tsx"
|
||||
case ".js":
|
||||
return "js"
|
||||
case ".jsx":
|
||||
return "jsx"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
18
packages/ipuaro/src/cli/commands/index.ts
Normal file
18
packages/ipuaro/src/cli/commands/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* CLI commands module.
|
||||
*/
|
||||
|
||||
export { executeStart, type StartOptions, type StartResult } from "./start.js"
|
||||
export { executeInit, type InitOptions, type InitResult } from "./init.js"
|
||||
export { executeIndex, type IndexResult, type IndexProgressCallback } from "./index-cmd.js"
|
||||
export {
|
||||
runOnboarding,
|
||||
checkRedis,
|
||||
checkOllama,
|
||||
checkModel,
|
||||
checkProjectSize,
|
||||
pullModel,
|
||||
type OnboardingResult,
|
||||
type OnboardingOptions,
|
||||
} from "./onboarding.js"
|
||||
export { registerAllTools } from "./tools-setup.js"
|
||||
114
packages/ipuaro/src/cli/commands/init.ts
Normal file
114
packages/ipuaro/src/cli/commands/init.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Init command implementation.
|
||||
* Creates .ipuaro.json configuration file.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
|
||||
/**
|
||||
* Default configuration template for .ipuaro.json
|
||||
*/
|
||||
const CONFIG_TEMPLATE = {
|
||||
$schema: "https://raw.githubusercontent.com/samiyev/puaros/main/packages/ipuaro/schema.json",
|
||||
redis: {
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
},
|
||||
llm: {
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
},
|
||||
project: {
|
||||
ignorePatterns: [],
|
||||
},
|
||||
edit: {
|
||||
autoApply: false,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for init command.
|
||||
*/
|
||||
export interface InitOptions {
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of init command.
|
||||
*/
|
||||
export interface InitResult {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
error?: string
|
||||
skipped?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the init command.
|
||||
* Creates a .ipuaro.json file in the specified directory.
|
||||
*/
|
||||
export async function executeInit(
|
||||
projectPath = ".",
|
||||
options: InitOptions = {},
|
||||
): Promise<InitResult> {
|
||||
const resolvedPath = path.resolve(projectPath)
|
||||
const configPath = path.join(resolvedPath, ".ipuaro.json")
|
||||
|
||||
try {
|
||||
const exists = await fileExists(configPath)
|
||||
|
||||
if (exists && !options.force) {
|
||||
console.warn(`⚠️ Configuration file already exists: ${configPath}`)
|
||||
console.warn(" Use --force to overwrite.")
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
filePath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
const dirExists = await fileExists(resolvedPath)
|
||||
if (!dirExists) {
|
||||
await fs.mkdir(resolvedPath, { recursive: true })
|
||||
}
|
||||
|
||||
const content = JSON.stringify(CONFIG_TEMPLATE, null, 4)
|
||||
await fs.writeFile(configPath, content, "utf-8")
|
||||
|
||||
console.warn(`✅ Created ${configPath}`)
|
||||
console.warn("\nConfiguration options:")
|
||||
console.warn(" redis.host - Redis server host (default: localhost)")
|
||||
console.warn(" redis.port - Redis server port (default: 6379)")
|
||||
console.warn(" llm.model - Ollama model name (default: qwen2.5-coder:7b-instruct)")
|
||||
console.warn(" llm.temperature - LLM temperature (default: 0.1)")
|
||||
console.warn(" edit.autoApply - Auto-apply edits without confirmation (default: false)")
|
||||
console.warn("\nRun `ipuaro` to start the AI agent.")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: configPath,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`❌ Failed to create configuration: ${message}`)
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file or directory exists.
|
||||
*/
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
290
packages/ipuaro/src/cli/commands/onboarding.ts
Normal file
290
packages/ipuaro/src/cli/commands/onboarding.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Onboarding checks for CLI.
|
||||
* Validates environment before starting ipuaro.
|
||||
*/
|
||||
|
||||
import { RedisClient } from "../../infrastructure/storage/RedisClient.js"
|
||||
import { OllamaClient } from "../../infrastructure/llm/OllamaClient.js"
|
||||
import { FileScanner } from "../../infrastructure/indexer/FileScanner.js"
|
||||
import type { LLMConfig, RedisConfig } from "../../shared/constants/config.js"
|
||||
|
||||
/**
|
||||
* Result of onboarding checks.
|
||||
*/
|
||||
export interface OnboardingResult {
|
||||
success: boolean
|
||||
redisOk: boolean
|
||||
ollamaOk: boolean
|
||||
modelOk: boolean
|
||||
projectOk: boolean
|
||||
fileCount: number
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for onboarding checks.
|
||||
*/
|
||||
export interface OnboardingOptions {
|
||||
redisConfig: RedisConfig
|
||||
llmConfig: LLMConfig
|
||||
projectPath: string
|
||||
maxFiles?: number
|
||||
skipRedis?: boolean
|
||||
skipOllama?: boolean
|
||||
skipModel?: boolean
|
||||
skipProject?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES = 10_000
|
||||
|
||||
/**
|
||||
* Check Redis availability.
|
||||
*/
|
||||
export async function checkRedis(config: RedisConfig): Promise<{
|
||||
ok: boolean
|
||||
error?: string
|
||||
}> {
|
||||
const client = new RedisClient(config)
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
const pingOk = await client.ping()
|
||||
await client.disconnect()
|
||||
|
||||
if (!pingOk) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Redis ping failed. Server may be overloaded.",
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
ok: false,
|
||||
error: `Cannot connect to Redis: ${message}
|
||||
|
||||
Redis is required for ipuaro to store project indexes and session data.
|
||||
|
||||
Install Redis:
|
||||
macOS: brew install redis && brew services start redis
|
||||
Ubuntu: sudo apt install redis-server && sudo systemctl start redis
|
||||
Docker: docker run -d -p 6379:6379 redis`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Ollama availability.
|
||||
*/
|
||||
export async function checkOllama(config: LLMConfig): Promise<{
|
||||
ok: boolean
|
||||
error?: string
|
||||
}> {
|
||||
const client = new OllamaClient(config)
|
||||
|
||||
try {
|
||||
const available = await client.isAvailable()
|
||||
|
||||
if (!available) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Cannot connect to Ollama at ${config.host}
|
||||
|
||||
Ollama is required for ipuaro to process your requests using local LLMs.
|
||||
|
||||
Install Ollama:
|
||||
macOS: brew install ollama && ollama serve
|
||||
Linux: curl -fsSL https://ollama.com/install.sh | sh && ollama serve
|
||||
Manual: https://ollama.com/download
|
||||
|
||||
After installing, ensure Ollama is running with: ollama serve`,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
ok: false,
|
||||
error: `Ollama check failed: ${message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check model availability.
|
||||
*/
|
||||
export async function checkModel(config: LLMConfig): Promise<{
|
||||
ok: boolean
|
||||
needsPull: boolean
|
||||
error?: string
|
||||
}> {
|
||||
const client = new OllamaClient(config)
|
||||
|
||||
try {
|
||||
const hasModel = await client.hasModel(config.model)
|
||||
|
||||
if (!hasModel) {
|
||||
return {
|
||||
ok: false,
|
||||
needsPull: true,
|
||||
error: `Model "${config.model}" is not installed.
|
||||
|
||||
Would you like to pull it? This may take a few minutes.
|
||||
Run: ollama pull ${config.model}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, needsPull: false }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
ok: false,
|
||||
needsPull: false,
|
||||
error: `Model check failed: ${message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull model from Ollama.
|
||||
*/
|
||||
export async function pullModel(
|
||||
config: LLMConfig,
|
||||
onProgress?: (status: string) => void,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const client = new OllamaClient(config)
|
||||
|
||||
try {
|
||||
onProgress?.(`Pulling model "${config.model}"...`)
|
||||
await client.pullModel(config.model)
|
||||
onProgress?.(`Model "${config.model}" pulled successfully.`)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to pull model: ${message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check project size.
|
||||
*/
|
||||
export async function checkProjectSize(
|
||||
projectPath: string,
|
||||
maxFiles: number = DEFAULT_MAX_FILES,
|
||||
): Promise<{
|
||||
ok: boolean
|
||||
fileCount: number
|
||||
warning?: string
|
||||
}> {
|
||||
const scanner = new FileScanner()
|
||||
|
||||
try {
|
||||
const files = await scanner.scanAll(projectPath)
|
||||
const fileCount = files.length
|
||||
|
||||
if (fileCount > maxFiles) {
|
||||
return {
|
||||
ok: true,
|
||||
fileCount,
|
||||
warning: `Project has ${fileCount.toLocaleString()} files (>${maxFiles.toLocaleString()}).
|
||||
This may take a while to index and use more memory.
|
||||
|
||||
Consider:
|
||||
1. Running ipuaro in a subdirectory: ipuaro ./src
|
||||
2. Adding patterns to .gitignore to exclude unnecessary files
|
||||
3. Using a smaller project for better performance`,
|
||||
}
|
||||
}
|
||||
|
||||
if (fileCount === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
fileCount: 0,
|
||||
warning: `No supported files found in "${projectPath}".
|
||||
|
||||
ipuaro supports: .ts, .tsx, .js, .jsx, .json, .yaml, .yml
|
||||
|
||||
Ensure you're running ipuaro in a project directory with source files.`,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, fileCount }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
ok: false,
|
||||
fileCount: 0,
|
||||
warning: `Failed to scan project: ${message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all onboarding checks.
|
||||
*/
|
||||
export async function runOnboarding(options: OnboardingOptions): Promise<OnboardingResult> {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES
|
||||
|
||||
let redisOk = true
|
||||
let ollamaOk = true
|
||||
let modelOk = true
|
||||
let projectOk = true
|
||||
let fileCount = 0
|
||||
|
||||
if (!options.skipRedis) {
|
||||
const redisResult = await checkRedis(options.redisConfig)
|
||||
redisOk = redisResult.ok
|
||||
if (!redisOk && redisResult.error) {
|
||||
errors.push(redisResult.error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.skipOllama) {
|
||||
const ollamaResult = await checkOllama(options.llmConfig)
|
||||
ollamaOk = ollamaResult.ok
|
||||
if (!ollamaOk && ollamaResult.error) {
|
||||
errors.push(ollamaResult.error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.skipModel && ollamaOk) {
|
||||
const modelResult = await checkModel(options.llmConfig)
|
||||
modelOk = modelResult.ok
|
||||
if (!modelOk && modelResult.error) {
|
||||
errors.push(modelResult.error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.skipProject) {
|
||||
const projectResult = await checkProjectSize(options.projectPath, maxFiles)
|
||||
projectOk = projectResult.ok
|
||||
fileCount = projectResult.fileCount
|
||||
if (projectResult.warning) {
|
||||
if (projectResult.ok) {
|
||||
warnings.push(projectResult.warning)
|
||||
} else {
|
||||
errors.push(projectResult.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: redisOk && ollamaOk && modelOk && projectOk && errors.length === 0,
|
||||
redisOk,
|
||||
ollamaOk,
|
||||
modelOk,
|
||||
projectOk,
|
||||
fileCount,
|
||||
errors,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
162
packages/ipuaro/src/cli/commands/start.ts
Normal file
162
packages/ipuaro/src/cli/commands/start.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Start command implementation.
|
||||
* Launches the ipuaro TUI.
|
||||
*/
|
||||
|
||||
import * as path from "node:path"
|
||||
import * as readline from "node:readline"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { App, type AppDependencies } from "../../tui/App.js"
|
||||
import { RedisClient } from "../../infrastructure/storage/RedisClient.js"
|
||||
import { RedisStorage } from "../../infrastructure/storage/RedisStorage.js"
|
||||
import { RedisSessionStorage } from "../../infrastructure/storage/RedisSessionStorage.js"
|
||||
import { OllamaClient } from "../../infrastructure/llm/OllamaClient.js"
|
||||
import { ToolRegistry } from "../../infrastructure/tools/registry.js"
|
||||
import { generateProjectName } from "../../infrastructure/storage/schema.js"
|
||||
import { type Config, DEFAULT_CONFIG } from "../../shared/constants/config.js"
|
||||
import { checkModel, pullModel, runOnboarding } from "./onboarding.js"
|
||||
import { registerAllTools } from "./tools-setup.js"
|
||||
|
||||
/**
|
||||
* Options for start command.
|
||||
*/
|
||||
export interface StartOptions {
|
||||
autoApply?: boolean
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of start command.
|
||||
*/
|
||||
export interface StartResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the start command.
|
||||
*/
|
||||
export async function executeStart(
|
||||
projectPath: string,
|
||||
options: StartOptions,
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
): Promise<StartResult> {
|
||||
const resolvedPath = path.resolve(projectPath)
|
||||
const projectName = generateProjectName(resolvedPath)
|
||||
|
||||
const llmConfig = {
|
||||
...config.llm,
|
||||
model: options.model ?? config.llm.model,
|
||||
}
|
||||
|
||||
console.warn("🔍 Running pre-flight checks...\n")
|
||||
|
||||
const onboardingResult = await runOnboarding({
|
||||
redisConfig: config.redis,
|
||||
llmConfig,
|
||||
projectPath: resolvedPath,
|
||||
})
|
||||
|
||||
for (const warning of onboardingResult.warnings) {
|
||||
console.warn(`⚠️ ${warning}\n`)
|
||||
}
|
||||
|
||||
if (!onboardingResult.success) {
|
||||
for (const error of onboardingResult.errors) {
|
||||
console.error(`❌ ${error}\n`)
|
||||
}
|
||||
|
||||
if (!onboardingResult.modelOk && onboardingResult.ollamaOk) {
|
||||
const shouldPull = await promptYesNo(
|
||||
`Would you like to pull "${llmConfig.model}"? (y/n): `,
|
||||
)
|
||||
|
||||
if (shouldPull) {
|
||||
const pullResult = await pullModel(llmConfig, console.warn)
|
||||
if (!pullResult.ok) {
|
||||
console.error(`❌ ${pullResult.error ?? "Unknown error"}`)
|
||||
return { success: false, error: pullResult.error }
|
||||
}
|
||||
|
||||
const recheckModel = await checkModel(llmConfig)
|
||||
if (!recheckModel.ok) {
|
||||
console.error("❌ Model still not available after pull.")
|
||||
return { success: false, error: "Model pull failed" }
|
||||
}
|
||||
} else {
|
||||
return { success: false, error: "Model not available" }
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: onboardingResult.errors.join("\n"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`✅ All checks passed. Found ${String(onboardingResult.fileCount)} files.\n`)
|
||||
console.warn("🚀 Starting ipuaro...\n")
|
||||
|
||||
const redisClient = new RedisClient(config.redis)
|
||||
|
||||
try {
|
||||
await redisClient.connect()
|
||||
|
||||
const storage = new RedisStorage(redisClient, projectName)
|
||||
const sessionStorage = new RedisSessionStorage(redisClient)
|
||||
const llm = new OllamaClient(llmConfig)
|
||||
const tools = new ToolRegistry()
|
||||
|
||||
registerAllTools(tools)
|
||||
|
||||
const deps: AppDependencies = {
|
||||
storage,
|
||||
sessionStorage,
|
||||
llm,
|
||||
tools,
|
||||
}
|
||||
|
||||
const handleExit = (): void => {
|
||||
void redisClient.disconnect()
|
||||
}
|
||||
|
||||
const { waitUntilExit } = render(
|
||||
React.createElement(App, {
|
||||
projectPath: resolvedPath,
|
||||
autoApply: options.autoApply ?? config.edit.autoApply,
|
||||
deps,
|
||||
onExit: handleExit,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitUntilExit()
|
||||
await redisClient.disconnect()
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`❌ Failed to start ipuaro: ${message}`)
|
||||
await redisClient.disconnect()
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple yes/no prompt for CLI.
|
||||
*/
|
||||
async function promptYesNo(question: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(question)
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
rl.once("line", (answer: string) => {
|
||||
rl.close()
|
||||
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes")
|
||||
})
|
||||
})
|
||||
}
|
||||
59
packages/ipuaro/src/cli/commands/tools-setup.ts
Normal file
59
packages/ipuaro/src/cli/commands/tools-setup.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Tool registration helper for CLI.
|
||||
* Registers all 18 tools with the tool registry.
|
||||
*/
|
||||
|
||||
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
|
||||
|
||||
import { GetLinesTool } from "../../infrastructure/tools/read/GetLinesTool.js"
|
||||
import { GetFunctionTool } from "../../infrastructure/tools/read/GetFunctionTool.js"
|
||||
import { GetClassTool } from "../../infrastructure/tools/read/GetClassTool.js"
|
||||
import { GetStructureTool } from "../../infrastructure/tools/read/GetStructureTool.js"
|
||||
|
||||
import { EditLinesTool } from "../../infrastructure/tools/edit/EditLinesTool.js"
|
||||
import { CreateFileTool } from "../../infrastructure/tools/edit/CreateFileTool.js"
|
||||
import { DeleteFileTool } from "../../infrastructure/tools/edit/DeleteFileTool.js"
|
||||
|
||||
import { FindReferencesTool } from "../../infrastructure/tools/search/FindReferencesTool.js"
|
||||
import { FindDefinitionTool } from "../../infrastructure/tools/search/FindDefinitionTool.js"
|
||||
|
||||
import { GetDependenciesTool } from "../../infrastructure/tools/analysis/GetDependenciesTool.js"
|
||||
import { GetDependentsTool } from "../../infrastructure/tools/analysis/GetDependentsTool.js"
|
||||
import { GetComplexityTool } from "../../infrastructure/tools/analysis/GetComplexityTool.js"
|
||||
import { GetTodosTool } from "../../infrastructure/tools/analysis/GetTodosTool.js"
|
||||
|
||||
import { GitStatusTool } from "../../infrastructure/tools/git/GitStatusTool.js"
|
||||
import { GitDiffTool } from "../../infrastructure/tools/git/GitDiffTool.js"
|
||||
import { GitCommitTool } from "../../infrastructure/tools/git/GitCommitTool.js"
|
||||
|
||||
import { RunCommandTool } from "../../infrastructure/tools/run/RunCommandTool.js"
|
||||
import { RunTestsTool } from "../../infrastructure/tools/run/RunTestsTool.js"
|
||||
|
||||
/**
|
||||
* Register all 18 tools with the tool registry.
|
||||
*/
|
||||
export function registerAllTools(registry: IToolRegistry): void {
|
||||
registry.register(new GetLinesTool())
|
||||
registry.register(new GetFunctionTool())
|
||||
registry.register(new GetClassTool())
|
||||
registry.register(new GetStructureTool())
|
||||
|
||||
registry.register(new EditLinesTool())
|
||||
registry.register(new CreateFileTool())
|
||||
registry.register(new DeleteFileTool())
|
||||
|
||||
registry.register(new FindReferencesTool())
|
||||
registry.register(new FindDefinitionTool())
|
||||
|
||||
registry.register(new GetDependenciesTool())
|
||||
registry.register(new GetDependentsTool())
|
||||
registry.register(new GetComplexityTool())
|
||||
registry.register(new GetTodosTool())
|
||||
|
||||
registry.register(new GitStatusTool())
|
||||
registry.register(new GitDiffTool())
|
||||
registry.register(new GitCommitTool())
|
||||
|
||||
registry.register(new RunCommandTool())
|
||||
registry.register(new RunTestsTool())
|
||||
}
|
||||
@@ -1,44 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* ipuaro CLI entry point.
|
||||
* Local AI agent for codebase operations with infinite context feeling.
|
||||
*/
|
||||
|
||||
import { createRequire } from "node:module"
|
||||
import { Command } from "commander"
|
||||
import { executeStart } from "./commands/start.js"
|
||||
import { executeInit } from "./commands/init.js"
|
||||
import { executeIndex } from "./commands/index-cmd.js"
|
||||
import { loadConfig } from "../shared/config/loader.js"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("../../package.json") as { version: string }
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.name("ipuaro")
|
||||
.description("Local AI agent for codebase operations with infinite context feeling")
|
||||
.version("0.1.0")
|
||||
.version(pkg.version)
|
||||
|
||||
program
|
||||
.command("start")
|
||||
.command("start", { isDefault: true })
|
||||
.description("Start ipuaro TUI in the current directory")
|
||||
.argument("[path]", "Project path", ".")
|
||||
.option("--auto-apply", "Enable auto-apply mode for edits")
|
||||
.option("--model <name>", "Override LLM model", "qwen2.5-coder:7b-instruct")
|
||||
.action((path: string, options: { autoApply?: boolean; model?: string }) => {
|
||||
const model = options.model ?? "default"
|
||||
const autoApply = options.autoApply ?? false
|
||||
console.warn(`Starting ipuaro in ${path}...`)
|
||||
console.warn(`Model: ${model}`)
|
||||
console.warn(`Auto-apply: ${autoApply ? "enabled" : "disabled"}`)
|
||||
console.warn("\nNot implemented yet. Coming in version 0.11.0!")
|
||||
.option("--model <name>", "Override LLM model")
|
||||
.action(async (projectPath: string, options: { autoApply?: boolean; model?: string }) => {
|
||||
const config = loadConfig(projectPath)
|
||||
const result = await executeStart(projectPath, options, config)
|
||||
if (!result.success) {
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command("init")
|
||||
.description("Create .ipuaro.json config file")
|
||||
.action(() => {
|
||||
console.warn("Creating .ipuaro.json...")
|
||||
console.warn("\nNot implemented yet. Coming in version 0.17.0!")
|
||||
.argument("[path]", "Project path", ".")
|
||||
.option("--force", "Overwrite existing config file")
|
||||
.action(async (projectPath: string, options: { force?: boolean }) => {
|
||||
const result = await executeInit(projectPath, options)
|
||||
if (!result.success) {
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command("index")
|
||||
.description("Index project without starting TUI")
|
||||
.argument("[path]", "Project path", ".")
|
||||
.action((path: string) => {
|
||||
console.warn(`Indexing ${path}...`)
|
||||
console.warn("\nNot implemented yet. Coming in version 0.3.0!")
|
||||
.action(async (projectPath: string) => {
|
||||
const config = loadConfig(projectPath)
|
||||
const result = await executeIndex(projectPath, config)
|
||||
if (!result.success) {
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
program.parse()
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./storage/index.js"
|
||||
export * from "./indexer/index.js"
|
||||
export * from "./llm/index.js"
|
||||
export * from "./tools/index.js"
|
||||
export * from "./security/index.js"
|
||||
|
||||
293
packages/ipuaro/src/infrastructure/security/PathValidator.ts
Normal file
293
packages/ipuaro/src/infrastructure/security/PathValidator.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import * as path from "node:path"
|
||||
import { promises as fs } from "node:fs"
|
||||
|
||||
/**
|
||||
* Path validation result classification.
|
||||
*/
|
||||
export type PathValidationStatus = "valid" | "invalid" | "outside_project"
|
||||
|
||||
/**
|
||||
* Result of path validation.
|
||||
*/
|
||||
export interface PathValidationResult {
|
||||
/** Validation status */
|
||||
status: PathValidationStatus
|
||||
/** Reason for the status */
|
||||
reason: string
|
||||
/** Normalized absolute path (only if valid) */
|
||||
absolutePath?: string
|
||||
/** Normalized relative path (only if valid) */
|
||||
relativePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for path validation.
|
||||
*/
|
||||
export interface PathValidatorOptions {
|
||||
/** Allow paths that don't exist yet (for create operations) */
|
||||
allowNonExistent?: boolean
|
||||
/** Check if path is a directory */
|
||||
requireDirectory?: boolean
|
||||
/** Check if path is a file */
|
||||
requireFile?: boolean
|
||||
/** Follow symlinks when checking existence */
|
||||
followSymlinks?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Path validator for ensuring file operations stay within project boundaries.
|
||||
* Prevents path traversal attacks and unauthorized file access.
|
||||
*/
|
||||
export class PathValidator {
|
||||
private readonly projectRoot: string
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.projectRoot = path.resolve(projectRoot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path and return detailed result.
|
||||
* @param inputPath - Path to validate (relative or absolute)
|
||||
* @param options - Validation options
|
||||
*/
|
||||
async validate(
|
||||
inputPath: string,
|
||||
options: PathValidatorOptions = {},
|
||||
): Promise<PathValidationResult> {
|
||||
if (!inputPath || inputPath.trim() === "") {
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: "Path is empty",
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedInput = inputPath.trim()
|
||||
|
||||
if (this.containsTraversalPatterns(normalizedInput)) {
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: "Path contains traversal patterns",
|
||||
}
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(this.projectRoot, normalizedInput)
|
||||
|
||||
if (!this.isWithinProject(absolutePath)) {
|
||||
return {
|
||||
status: "outside_project",
|
||||
reason: "Path is outside project root",
|
||||
}
|
||||
}
|
||||
|
||||
const relativePath = path.relative(this.projectRoot, absolutePath)
|
||||
|
||||
if (!options.allowNonExistent) {
|
||||
const existsResult = await this.checkExists(absolutePath, options)
|
||||
if (existsResult) {
|
||||
return existsResult
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "valid",
|
||||
reason: "Path is valid",
|
||||
absolutePath,
|
||||
relativePath,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous validation for simple checks.
|
||||
* Does not check file existence or type.
|
||||
* @param inputPath - Path to validate (relative or absolute)
|
||||
*/
|
||||
validateSync(inputPath: string): PathValidationResult {
|
||||
if (!inputPath || inputPath.trim() === "") {
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: "Path is empty",
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedInput = inputPath.trim()
|
||||
|
||||
if (this.containsTraversalPatterns(normalizedInput)) {
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: "Path contains traversal patterns",
|
||||
}
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(this.projectRoot, normalizedInput)
|
||||
|
||||
if (!this.isWithinProject(absolutePath)) {
|
||||
return {
|
||||
status: "outside_project",
|
||||
reason: "Path is outside project root",
|
||||
}
|
||||
}
|
||||
|
||||
const relativePath = path.relative(this.projectRoot, absolutePath)
|
||||
|
||||
return {
|
||||
status: "valid",
|
||||
reason: "Path is valid",
|
||||
absolutePath,
|
||||
relativePath,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if path is within project.
|
||||
* @param inputPath - Path to check (relative or absolute)
|
||||
*/
|
||||
isWithin(inputPath: string): boolean {
|
||||
if (!inputPath || inputPath.trim() === "") {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedInput = inputPath.trim()
|
||||
|
||||
if (this.containsTraversalPatterns(normalizedInput)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(this.projectRoot, normalizedInput)
|
||||
return this.isWithinProject(absolutePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path relative to project root.
|
||||
* Returns null if path would be outside project.
|
||||
* @param inputPath - Path to resolve
|
||||
*/
|
||||
resolve(inputPath: string): string | null {
|
||||
const result = this.validateSync(inputPath)
|
||||
return result.status === "valid" ? (result.absolutePath ?? null) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path or throw an error if invalid.
|
||||
* @param inputPath - Path to resolve
|
||||
* @returns Tuple of [absolutePath, relativePath]
|
||||
* @throws Error if path is invalid
|
||||
*/
|
||||
resolveOrThrow(inputPath: string): [absolutePath: string, relativePath: string] {
|
||||
const result = this.validateSync(inputPath)
|
||||
if (result.status !== "valid" || result.absolutePath === undefined) {
|
||||
throw new Error(result.reason)
|
||||
}
|
||||
return [result.absolutePath, result.relativePath ?? ""]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative path from project root.
|
||||
* Returns null if path would be outside project.
|
||||
* @param inputPath - Path to make relative
|
||||
*/
|
||||
relativize(inputPath: string): string | null {
|
||||
const result = this.validateSync(inputPath)
|
||||
return result.status === "valid" ? (result.relativePath ?? null) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project root path.
|
||||
*/
|
||||
getProjectRoot(): string {
|
||||
return this.projectRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path contains directory traversal patterns.
|
||||
*/
|
||||
private containsTraversalPatterns(inputPath: string): boolean {
|
||||
const normalized = inputPath.replace(/\\/g, "/")
|
||||
|
||||
if (normalized.includes("..")) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (normalized.startsWith("~")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if absolute path is within project root.
|
||||
*/
|
||||
private isWithinProject(absolutePath: string): boolean {
|
||||
const normalizedProject = this.projectRoot.replace(/\\/g, "/")
|
||||
const normalizedPath = absolutePath.replace(/\\/g, "/")
|
||||
|
||||
if (normalizedPath === normalizedProject) {
|
||||
return true
|
||||
}
|
||||
|
||||
const projectWithSep = normalizedProject.endsWith("/")
|
||||
? normalizedProject
|
||||
: `${normalizedProject}/`
|
||||
|
||||
return normalizedPath.startsWith(projectWithSep)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check file existence and type.
|
||||
*/
|
||||
private async checkExists(
|
||||
absolutePath: string,
|
||||
options: PathValidatorOptions,
|
||||
): Promise<PathValidationResult | null> {
|
||||
try {
|
||||
const statFn = options.followSymlinks ? fs.stat : fs.lstat
|
||||
const stats = await statFn(absolutePath)
|
||||
|
||||
if (options.requireDirectory && !stats.isDirectory()) {
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: "Path is not a directory",
|
||||
}
|
||||
}
|
||||
|
||||
if (options.requireFile && !stats.isFile()) {
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: "Path is not a file",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: "Path does not exist",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "invalid",
|
||||
reason: `Cannot access path: ${(error as Error).message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a path validator for a project.
|
||||
* @param projectRoot - Root directory of the project
|
||||
*/
|
||||
export function createPathValidator(projectRoot: string): PathValidator {
|
||||
return new PathValidator(projectRoot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone function for quick path validation.
|
||||
* @param inputPath - Path to validate
|
||||
* @param projectRoot - Project root directory
|
||||
*/
|
||||
export function validatePath(inputPath: string, projectRoot: string): boolean {
|
||||
const validator = new PathValidator(projectRoot)
|
||||
return validator.isWithin(inputPath)
|
||||
}
|
||||
9
packages/ipuaro/src/infrastructure/security/index.ts
Normal file
9
packages/ipuaro/src/infrastructure/security/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Security module exports
|
||||
export {
|
||||
PathValidator,
|
||||
createPathValidator,
|
||||
validatePath,
|
||||
type PathValidationResult,
|
||||
type PathValidationStatus,
|
||||
type PathValidatorOptions,
|
||||
} from "./PathValidator.js"
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { hashLines } from "../../../shared/utils/hash.js"
|
||||
import { PathValidator } from "../../security/PathValidator.js"
|
||||
|
||||
/**
|
||||
* Result data from create_file tool.
|
||||
@@ -62,17 +63,18 @@ export class CreateFileTool implements ITool {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const inputPath = params.path as string
|
||||
const content = params.content as string
|
||||
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
let absolutePath: string
|
||||
let relativePath: string
|
||||
try {
|
||||
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { PathValidator } from "../../security/PathValidator.js"
|
||||
|
||||
/**
|
||||
* Result data from delete_file tool.
|
||||
@@ -49,15 +49,16 @@ export class DeleteFileTool implements ITool {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
const inputPath = params.path as string
|
||||
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
let absolutePath: string
|
||||
let relativePath: string
|
||||
try {
|
||||
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import { createFileData } from "../../../domain/value-objects/FileData.js"
|
||||
import {
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { hashLines } from "../../../shared/utils/hash.js"
|
||||
import { PathValidator } from "../../security/PathValidator.js"
|
||||
|
||||
/**
|
||||
* Result data from edit_lines tool.
|
||||
@@ -94,19 +94,20 @@ export class EditLinesTool implements ITool {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const inputPath = params.path as string
|
||||
const startLine = params.start as number
|
||||
const endLine = params.end as number
|
||||
const newContent = params.content as string
|
||||
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
let absolutePath: string
|
||||
let relativePath: string
|
||||
try {
|
||||
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import type { ClassInfo } from "../../../domain/value-objects/FileAST.js"
|
||||
import {
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { PathValidator } from "../../security/PathValidator.js"
|
||||
|
||||
/**
|
||||
* Result data from get_class tool.
|
||||
@@ -67,16 +67,17 @@ export class GetClassTool implements ITool {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const inputPath = params.path as string
|
||||
const className = params.name as string
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
let absolutePath: string
|
||||
let relativePath: string
|
||||
try {
|
||||
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import type { FunctionInfo } from "../../../domain/value-objects/FileAST.js"
|
||||
import {
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { PathValidator } from "../../security/PathValidator.js"
|
||||
|
||||
/**
|
||||
* Result data from get_function tool.
|
||||
@@ -65,16 +65,17 @@ export class GetFunctionTool implements ITool {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const inputPath = params.path as string
|
||||
const functionName = params.name as string
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
let absolutePath: string
|
||||
let relativePath: string
|
||||
try {
|
||||
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { PathValidator } from "../../security/PathValidator.js"
|
||||
|
||||
/**
|
||||
* Result data from get_lines tool.
|
||||
@@ -84,15 +84,16 @@ export class GetLinesTool implements ITool {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = params.path as string
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
const inputPath = params.path as string
|
||||
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
let absolutePath: string
|
||||
let relativePath: string
|
||||
try {
|
||||
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js"
|
||||
import { PathValidator } from "../../security/PathValidator.js"
|
||||
|
||||
/**
|
||||
* Tree node representing a file or directory.
|
||||
@@ -89,16 +90,17 @@ export class GetStructureTool implements ITool {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const relativePath = (params.path as string | undefined) ?? ""
|
||||
const inputPath = (params.path as string | undefined) ?? "."
|
||||
const maxDepth = params.depth as number | undefined
|
||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
||||
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||
|
||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Path must be within project root",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
let absolutePath: string
|
||||
let relativePath: string
|
||||
try {
|
||||
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ErrorChoice } from "../shared/types/index.js"
|
||||
import type { IToolRegistry } from "../application/interfaces/IToolRegistry.js"
|
||||
import type { ProjectStructure } from "../infrastructure/llm/prompts.js"
|
||||
import { Chat, Input, StatusBar } from "./components/index.js"
|
||||
import { useHotkeys, useSession } from "./hooks/index.js"
|
||||
import { type CommandResult, useCommands, useHotkeys, useSession } from "./hooks/index.js"
|
||||
import type { AppProps, BranchInfo } from "./types.js"
|
||||
|
||||
export interface AppDependencies {
|
||||
@@ -58,7 +58,7 @@ async function handleErrorDefault(_error: Error): Promise<ErrorChoice> {
|
||||
|
||||
export function App({
|
||||
projectPath,
|
||||
autoApply = false,
|
||||
autoApply: initialAutoApply = false,
|
||||
deps,
|
||||
onExit,
|
||||
}: ExtendedAppProps): React.JSX.Element {
|
||||
@@ -66,24 +66,54 @@ export function App({
|
||||
|
||||
const [branch] = useState<BranchInfo>({ name: "main", isDetached: false })
|
||||
const [sessionTime, setSessionTime] = useState("0m")
|
||||
const [autoApply, setAutoApply] = useState(initialAutoApply)
|
||||
const [commandResult, setCommandResult] = useState<CommandResult | null>(null)
|
||||
|
||||
const projectName = projectPath.split("/").pop() ?? "unknown"
|
||||
|
||||
const { session, messages, status, isLoading, error, sendMessage, undo, abort } = useSession(
|
||||
const { session, messages, status, isLoading, error, sendMessage, undo, clearHistory, abort } =
|
||||
useSession(
|
||||
{
|
||||
storage: deps.storage,
|
||||
sessionStorage: deps.sessionStorage,
|
||||
llm: deps.llm,
|
||||
tools: deps.tools,
|
||||
projectRoot: projectPath,
|
||||
projectName,
|
||||
projectStructure: deps.projectStructure,
|
||||
},
|
||||
{
|
||||
autoApply,
|
||||
onConfirmation: handleConfirmationDefault,
|
||||
onError: handleErrorDefault,
|
||||
},
|
||||
)
|
||||
|
||||
const reindex = useCallback(async (): Promise<void> => {
|
||||
/*
|
||||
* TODO: Implement full reindex via IndexProject use case
|
||||
* For now, this is a placeholder
|
||||
*/
|
||||
await Promise.resolve()
|
||||
}, [])
|
||||
|
||||
const { executeCommand, isCommand } = useCommands(
|
||||
{
|
||||
storage: deps.storage,
|
||||
session,
|
||||
sessionStorage: deps.sessionStorage,
|
||||
storage: deps.storage,
|
||||
llm: deps.llm,
|
||||
tools: deps.tools,
|
||||
projectRoot: projectPath,
|
||||
projectName,
|
||||
projectStructure: deps.projectStructure,
|
||||
},
|
||||
{
|
||||
autoApply,
|
||||
onConfirmation: handleConfirmationDefault,
|
||||
onError: handleErrorDefault,
|
||||
clearHistory,
|
||||
undo,
|
||||
setAutoApply,
|
||||
reindex,
|
||||
},
|
||||
{ autoApply },
|
||||
)
|
||||
|
||||
const handleExit = useCallback((): void => {
|
||||
@@ -128,12 +158,19 @@ export function App({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string): void => {
|
||||
if (text.startsWith("/")) {
|
||||
if (isCommand(text)) {
|
||||
void executeCommand(text).then((result) => {
|
||||
setCommandResult(result)
|
||||
// Auto-clear command result after 5 seconds
|
||||
setTimeout(() => {
|
||||
setCommandResult(null)
|
||||
}, 5000)
|
||||
})
|
||||
return
|
||||
}
|
||||
void sendMessage(text)
|
||||
},
|
||||
[sendMessage],
|
||||
[sendMessage, isCommand, executeCommand],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
@@ -156,6 +193,18 @@ export function App({
|
||||
status={status}
|
||||
/>
|
||||
<Chat messages={messages} isThinking={status === "thinking"} />
|
||||
{commandResult && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={commandResult.success ? "green" : "red"}
|
||||
paddingX={1}
|
||||
marginY={1}
|
||||
>
|
||||
<Text color={commandResult.success ? "green" : "red"} wrap="wrap">
|
||||
{commandResult.message}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Input
|
||||
onSubmit={handleSubmit}
|
||||
history={session?.inputHistory ?? []}
|
||||
|
||||
83
packages/ipuaro/src/tui/components/ConfirmDialog.tsx
Normal file
83
packages/ipuaro/src/tui/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ConfirmDialog component for TUI.
|
||||
* Displays a confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options.
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import type { ConfirmChoice } from "../../shared/types/index.js"
|
||||
import { DiffView, type DiffViewProps } from "./DiffView.js"
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
message: string
|
||||
diff?: DiffViewProps
|
||||
onSelect: (choice: ConfirmChoice) => void
|
||||
}
|
||||
|
||||
function ChoiceButton({
|
||||
hotkey,
|
||||
label,
|
||||
isSelected,
|
||||
}: {
|
||||
hotkey: string
|
||||
label: string
|
||||
isSelected: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : "gray"}>
|
||||
[<Text bold>{hotkey}</Text>] {label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ message, diff, onSelect }: ConfirmDialogProps): React.JSX.Element {
|
||||
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
|
||||
|
||||
useInput((input, key) => {
|
||||
const lowerInput = input.toLowerCase()
|
||||
|
||||
if (lowerInput === "y") {
|
||||
setSelected("apply")
|
||||
onSelect("apply")
|
||||
} else if (lowerInput === "n") {
|
||||
setSelected("cancel")
|
||||
onSelect("cancel")
|
||||
} else if (lowerInput === "e") {
|
||||
setSelected("edit")
|
||||
onSelect("edit")
|
||||
} else if (key.escape) {
|
||||
setSelected("cancel")
|
||||
onSelect("cancel")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text color="yellow" bold>
|
||||
⚠ {message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{diff && (
|
||||
<Box marginBottom={1}>
|
||||
<DiffView {...diff} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box gap={2}>
|
||||
<ChoiceButton hotkey="Y" label="Apply" isSelected={selected === "apply"} />
|
||||
<ChoiceButton hotkey="N" label="Cancel" isSelected={selected === "cancel"} />
|
||||
<ChoiceButton hotkey="E" label="Edit" isSelected={selected === "edit"} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
193
packages/ipuaro/src/tui/components/DiffView.tsx
Normal file
193
packages/ipuaro/src/tui/components/DiffView.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* DiffView component for TUI.
|
||||
* Displays inline diff with green (added) and red (removed) highlighting.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import type React from "react"
|
||||
|
||||
export interface DiffViewProps {
|
||||
filePath: string
|
||||
oldLines: string[]
|
||||
newLines: string[]
|
||||
startLine: number
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: "add" | "remove" | "context"
|
||||
content: string
|
||||
lineNumber?: number
|
||||
}
|
||||
|
||||
function computeDiff(oldLines: string[], newLines: string[], startLine: number): DiffLine[] {
|
||||
const result: DiffLine[] = []
|
||||
|
||||
let oldIdx = 0
|
||||
let newIdx = 0
|
||||
|
||||
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
||||
const oldLine = oldIdx < oldLines.length ? oldLines[oldIdx] : undefined
|
||||
const newLine = newIdx < newLines.length ? newLines[newIdx] : undefined
|
||||
|
||||
if (oldLine === newLine) {
|
||||
result.push({
|
||||
type: "context",
|
||||
content: oldLine ?? "",
|
||||
lineNumber: startLine + newIdx,
|
||||
})
|
||||
oldIdx++
|
||||
newIdx++
|
||||
} else {
|
||||
if (oldLine !== undefined) {
|
||||
result.push({
|
||||
type: "remove",
|
||||
content: oldLine,
|
||||
})
|
||||
oldIdx++
|
||||
}
|
||||
if (newLine !== undefined) {
|
||||
result.push({
|
||||
type: "add",
|
||||
content: newLine,
|
||||
lineNumber: startLine + newIdx,
|
||||
})
|
||||
newIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getLinePrefix(line: DiffLine): string {
|
||||
switch (line.type) {
|
||||
case "add": {
|
||||
return "+"
|
||||
}
|
||||
case "remove": {
|
||||
return "-"
|
||||
}
|
||||
case "context": {
|
||||
return " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLineColor(line: DiffLine): string {
|
||||
switch (line.type) {
|
||||
case "add": {
|
||||
return "green"
|
||||
}
|
||||
case "remove": {
|
||||
return "red"
|
||||
}
|
||||
case "context": {
|
||||
return "gray"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatLineNumber(num: number | undefined, width: number): string {
|
||||
if (num === undefined) {
|
||||
return " ".repeat(width)
|
||||
}
|
||||
return String(num).padStart(width, " ")
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
line,
|
||||
lineNumberWidth,
|
||||
}: {
|
||||
line: DiffLine
|
||||
lineNumberWidth: number
|
||||
}): React.JSX.Element {
|
||||
const prefix = getLinePrefix(line)
|
||||
const color = getLineColor(line)
|
||||
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">{lineNum} </Text>
|
||||
<Text color={color}>
|
||||
{prefix} {line.content}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffHeader({
|
||||
filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
}: {
|
||||
filePath: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
}): React.JSX.Element {
|
||||
const lineRange =
|
||||
startLine === endLine
|
||||
? `line ${String(startLine)}`
|
||||
: `lines ${String(startLine)}-${String(endLine)}`
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">┌─── </Text>
|
||||
<Text color="cyan">{filePath}</Text>
|
||||
<Text color="gray"> ({lineRange}) ───┐</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffFooter(): React.JSX.Element {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">└───────────────────────────────────────┘</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffStats({
|
||||
additions,
|
||||
deletions,
|
||||
}: {
|
||||
additions: number
|
||||
deletions: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Box gap={1} marginTop={1}>
|
||||
<Text color="green">+{String(additions)}</Text>
|
||||
<Text color="red">-{String(deletions)}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiffView({
|
||||
filePath,
|
||||
oldLines,
|
||||
newLines,
|
||||
startLine,
|
||||
}: DiffViewProps): React.JSX.Element {
|
||||
const diffLines = computeDiff(oldLines, newLines, startLine)
|
||||
const endLine = startLine + newLines.length - 1
|
||||
const lineNumberWidth = String(endLine).length
|
||||
|
||||
const additions = diffLines.filter((l) => l.type === "add").length
|
||||
const deletions = diffLines.filter((l) => l.type === "remove").length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{diffLines.map((line, index) => (
|
||||
<DiffLine
|
||||
key={`${line.type}-${String(index)}`}
|
||||
line={line}
|
||||
lineNumberWidth={lineNumberWidth}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<DiffFooter />
|
||||
<DiffStats additions={additions} deletions={deletions} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
105
packages/ipuaro/src/tui/components/ErrorDialog.tsx
Normal file
105
packages/ipuaro/src/tui/components/ErrorDialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* ErrorDialog component for TUI.
|
||||
* Displays an error with [R] Retry / [S] Skip / [A] Abort options.
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import type { ErrorChoice } from "../../shared/types/index.js"
|
||||
|
||||
export interface ErrorInfo {
|
||||
type: string
|
||||
message: string
|
||||
recoverable: boolean
|
||||
}
|
||||
|
||||
export interface ErrorDialogProps {
|
||||
error: ErrorInfo
|
||||
onChoice: (choice: ErrorChoice) => void
|
||||
}
|
||||
|
||||
function ChoiceButton({
|
||||
hotkey,
|
||||
label,
|
||||
isSelected,
|
||||
disabled,
|
||||
}: {
|
||||
hotkey: string
|
||||
label: string
|
||||
isSelected: boolean
|
||||
disabled?: boolean
|
||||
}): React.JSX.Element {
|
||||
if (disabled) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray" dimColor>
|
||||
[{hotkey}] {label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : "gray"}>
|
||||
[<Text bold>{hotkey}</Text>] {label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorDialog({ error, onChoice }: ErrorDialogProps): React.JSX.Element {
|
||||
const [selected, setSelected] = useState<ErrorChoice | null>(null)
|
||||
|
||||
useInput((input, key) => {
|
||||
const lowerInput = input.toLowerCase()
|
||||
|
||||
if (lowerInput === "r" && error.recoverable) {
|
||||
setSelected("retry")
|
||||
onChoice("retry")
|
||||
} else if (lowerInput === "s" && error.recoverable) {
|
||||
setSelected("skip")
|
||||
onChoice("skip")
|
||||
} else if (lowerInput === "a") {
|
||||
setSelected("abort")
|
||||
onChoice("abort")
|
||||
} else if (key.escape) {
|
||||
setSelected("abort")
|
||||
onChoice("abort")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1} paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color="red" bold>
|
||||
x {error.type}: {error.message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box gap={2}>
|
||||
<ChoiceButton
|
||||
hotkey="R"
|
||||
label="Retry"
|
||||
isSelected={selected === "retry"}
|
||||
disabled={!error.recoverable}
|
||||
/>
|
||||
<ChoiceButton
|
||||
hotkey="S"
|
||||
label="Skip"
|
||||
isSelected={selected === "skip"}
|
||||
disabled={!error.recoverable}
|
||||
/>
|
||||
<ChoiceButton hotkey="A" label="Abort" isSelected={selected === "abort"} />
|
||||
</Box>
|
||||
|
||||
{!error.recoverable && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
This error is not recoverable. Press [A] to abort.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
62
packages/ipuaro/src/tui/components/Progress.tsx
Normal file
62
packages/ipuaro/src/tui/components/Progress.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Progress component for TUI.
|
||||
* Displays a progress bar: [=====> ] 45% (120/267 files)
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import type React from "react"
|
||||
|
||||
export interface ProgressProps {
|
||||
current: number
|
||||
total: number
|
||||
label: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
function calculatePercentage(current: number, total: number): number {
|
||||
if (total === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(100, Math.round((current / total) * 100))
|
||||
}
|
||||
|
||||
function createProgressBar(percentage: number, width: number): { filled: string; empty: string } {
|
||||
const filledWidth = Math.round((percentage / 100) * width)
|
||||
const emptyWidth = width - filledWidth
|
||||
|
||||
const filled = "=".repeat(Math.max(0, filledWidth - 1)) + (filledWidth > 0 ? ">" : "")
|
||||
const empty = " ".repeat(Math.max(0, emptyWidth))
|
||||
|
||||
return { filled, empty }
|
||||
}
|
||||
|
||||
function getProgressColor(percentage: number): string {
|
||||
if (percentage >= 100) {
|
||||
return "green"
|
||||
}
|
||||
if (percentage >= 50) {
|
||||
return "yellow"
|
||||
}
|
||||
return "cyan"
|
||||
}
|
||||
|
||||
export function Progress({ current, total, label, width = 30 }: ProgressProps): React.JSX.Element {
|
||||
const percentage = calculatePercentage(current, total)
|
||||
const { filled, empty } = createProgressBar(percentage, width)
|
||||
const color = getProgressColor(percentage)
|
||||
|
||||
return (
|
||||
<Box gap={1}>
|
||||
<Text color="gray">[</Text>
|
||||
<Text color={color}>{filled}</Text>
|
||||
<Text color="gray">{empty}</Text>
|
||||
<Text color="gray">]</Text>
|
||||
<Text color={color} bold>
|
||||
{String(percentage)}%
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
({String(current)}/{String(total)} {label})
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -5,3 +5,7 @@
|
||||
export { StatusBar, type StatusBarProps } from "./StatusBar.js"
|
||||
export { Chat, type ChatProps } from "./Chat.js"
|
||||
export { Input, type InputProps } from "./Input.js"
|
||||
export { DiffView, type DiffViewProps } from "./DiffView.js"
|
||||
export { ConfirmDialog, type ConfirmDialogProps } from "./ConfirmDialog.js"
|
||||
export { ErrorDialog, type ErrorDialogProps, type ErrorInfo } from "./ErrorDialog.js"
|
||||
export { Progress, type ProgressProps } from "./Progress.js"
|
||||
|
||||
@@ -9,3 +9,13 @@ export {
|
||||
type UseSessionReturn,
|
||||
} from "./useSession.js"
|
||||
export { useHotkeys, type HotkeyHandlers, type UseHotkeysOptions } from "./useHotkeys.js"
|
||||
export {
|
||||
useCommands,
|
||||
parseCommand,
|
||||
type UseCommandsDependencies,
|
||||
type UseCommandsActions,
|
||||
type UseCommandsOptions,
|
||||
type UseCommandsReturn,
|
||||
type CommandResult,
|
||||
type CommandDefinition,
|
||||
} from "./useCommands.js"
|
||||
|
||||
444
packages/ipuaro/src/tui/hooks/useCommands.ts
Normal file
444
packages/ipuaro/src/tui/hooks/useCommands.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* useCommands hook for TUI.
|
||||
* Handles slash commands (/help, /clear, /undo, etc.)
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { Session } from "../../domain/entities/Session.js"
|
||||
import type { ILLMClient } from "../../domain/services/ILLMClient.js"
|
||||
import type { ISessionStorage } from "../../domain/services/ISessionStorage.js"
|
||||
import type { IStorage } from "../../domain/services/IStorage.js"
|
||||
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
|
||||
|
||||
/**
|
||||
* Command result returned after execution.
|
||||
*/
|
||||
export interface CommandResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Command definition.
|
||||
*/
|
||||
export interface CommandDefinition {
|
||||
name: string
|
||||
description: string
|
||||
usage: string
|
||||
execute: (args: string[]) => Promise<CommandResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies for useCommands hook.
|
||||
*/
|
||||
export interface UseCommandsDependencies {
|
||||
session: Session | null
|
||||
sessionStorage: ISessionStorage
|
||||
storage: IStorage
|
||||
llm: ILLMClient
|
||||
tools: IToolRegistry
|
||||
projectRoot: string
|
||||
projectName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions provided by the parent component.
|
||||
*/
|
||||
export interface UseCommandsActions {
|
||||
clearHistory: () => void
|
||||
undo: () => Promise<boolean>
|
||||
setAutoApply: (value: boolean) => void
|
||||
reindex: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for useCommands hook.
|
||||
*/
|
||||
export interface UseCommandsOptions {
|
||||
autoApply: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useCommands hook.
|
||||
*/
|
||||
export interface UseCommandsReturn {
|
||||
executeCommand: (input: string) => Promise<CommandResult | null>
|
||||
isCommand: (input: string) => boolean
|
||||
getCommands: () => CommandDefinition[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses command input into command name and arguments.
|
||||
*/
|
||||
export function parseCommand(input: string): { command: string; args: string[] } | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts = trimmed.slice(1).split(/\s+/)
|
||||
const command = parts[0]?.toLowerCase() ?? ""
|
||||
const args = parts.slice(1)
|
||||
|
||||
return { command, args }
|
||||
}
|
||||
|
||||
// Command factory functions to keep the hook clean and under line limits
|
||||
|
||||
function createHelpCommand(map: Map<string, CommandDefinition>): CommandDefinition {
|
||||
return {
|
||||
name: "help",
|
||||
description: "Shows all commands and hotkeys",
|
||||
usage: "/help",
|
||||
execute: async (): Promise<CommandResult> => {
|
||||
const commandList = Array.from(map.values())
|
||||
.map((cmd) => ` ${cmd.usage.padEnd(25)} ${cmd.description}`)
|
||||
.join("\n")
|
||||
|
||||
const hotkeys = [
|
||||
" Ctrl+C (1x) Interrupt current operation",
|
||||
" Ctrl+C (2x) Exit ipuaro",
|
||||
" Ctrl+D Exit with session save",
|
||||
" Ctrl+Z Undo last change",
|
||||
" ↑/↓ Navigate input history",
|
||||
].join("\n")
|
||||
|
||||
const message = ["Available commands:", commandList, "", "Hotkeys:", hotkeys].join("\n")
|
||||
|
||||
return Promise.resolve({ success: true, message })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createClearCommand(actions: UseCommandsActions): CommandDefinition {
|
||||
return {
|
||||
name: "clear",
|
||||
description: "Clears chat history (keeps session)",
|
||||
usage: "/clear",
|
||||
execute: async (): Promise<CommandResult> => {
|
||||
actions.clearHistory()
|
||||
return Promise.resolve({ success: true, message: "Chat history cleared." })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createUndoCommand(
|
||||
deps: UseCommandsDependencies,
|
||||
actions: UseCommandsActions,
|
||||
): CommandDefinition {
|
||||
return {
|
||||
name: "undo",
|
||||
description: "Reverts last file change",
|
||||
usage: "/undo",
|
||||
execute: async (): Promise<CommandResult> => {
|
||||
if (!deps.session) {
|
||||
return { success: false, message: "No active session." }
|
||||
}
|
||||
|
||||
const undoStack = deps.session.undoStack
|
||||
if (undoStack.length === 0) {
|
||||
return { success: false, message: "Nothing to undo." }
|
||||
}
|
||||
|
||||
const result = await actions.undo()
|
||||
if (result) {
|
||||
return { success: true, message: "Last change reverted." }
|
||||
}
|
||||
return { success: false, message: "Failed to undo. File may have been modified." }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionsCommand(deps: UseCommandsDependencies): CommandDefinition {
|
||||
return {
|
||||
name: "sessions",
|
||||
description: "Manage sessions (list, load <id>, delete <id>)",
|
||||
usage: "/sessions [list|load|delete] [id]",
|
||||
execute: async (args: string[]): Promise<CommandResult> => {
|
||||
const subCommand = args[0]?.toLowerCase() ?? "list"
|
||||
|
||||
if (subCommand === "list") {
|
||||
return handleSessionsList(deps)
|
||||
}
|
||||
|
||||
if (subCommand === "load") {
|
||||
return handleSessionsLoad(deps, args[1])
|
||||
}
|
||||
|
||||
if (subCommand === "delete") {
|
||||
return handleSessionsDelete(deps, args[1])
|
||||
}
|
||||
|
||||
return { success: false, message: "Usage: /sessions [list|load|delete] [id]" }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionsList(deps: UseCommandsDependencies): Promise<CommandResult> {
|
||||
const sessions = await deps.sessionStorage.listSessions(deps.projectName)
|
||||
if (sessions.length === 0) {
|
||||
return { success: true, message: "No sessions found." }
|
||||
}
|
||||
|
||||
const currentId = deps.session?.id
|
||||
const sessionList = sessions
|
||||
.map((s) => {
|
||||
const current = s.id === currentId ? " (current)" : ""
|
||||
const date = new Date(s.createdAt).toLocaleString()
|
||||
return ` ${s.id.slice(0, 8)}${current} - ${date} - ${String(s.messageCount)} messages`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Sessions for ${deps.projectName}:\n${sessionList}`,
|
||||
data: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionsLoad(
|
||||
deps: UseCommandsDependencies,
|
||||
sessionId: string | undefined,
|
||||
): Promise<CommandResult> {
|
||||
if (!sessionId) {
|
||||
return { success: false, message: "Usage: /sessions load <id>" }
|
||||
}
|
||||
|
||||
const exists = await deps.sessionStorage.sessionExists(sessionId)
|
||||
if (!exists) {
|
||||
return { success: false, message: `Session ${sessionId} not found.` }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `To load session ${sessionId}, restart ipuaro with --session ${sessionId}`,
|
||||
data: { sessionId },
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionsDelete(
|
||||
deps: UseCommandsDependencies,
|
||||
sessionId: string | undefined,
|
||||
): Promise<CommandResult> {
|
||||
if (!sessionId) {
|
||||
return { success: false, message: "Usage: /sessions delete <id>" }
|
||||
}
|
||||
|
||||
if (deps.session?.id === sessionId) {
|
||||
return { success: false, message: "Cannot delete current session." }
|
||||
}
|
||||
|
||||
const exists = await deps.sessionStorage.sessionExists(sessionId)
|
||||
if (!exists) {
|
||||
return { success: false, message: `Session ${sessionId} not found.` }
|
||||
}
|
||||
|
||||
await deps.sessionStorage.deleteSession(sessionId)
|
||||
return { success: true, message: `Session ${sessionId} deleted.` }
|
||||
}
|
||||
|
||||
function createStatusCommand(
|
||||
deps: UseCommandsDependencies,
|
||||
options: UseCommandsOptions,
|
||||
): CommandDefinition {
|
||||
return {
|
||||
name: "status",
|
||||
description: "Shows system and session status",
|
||||
usage: "/status",
|
||||
execute: async (): Promise<CommandResult> => {
|
||||
const llmAvailable = await deps.llm.isAvailable()
|
||||
const llmStatus = llmAvailable ? "connected" : "unavailable"
|
||||
|
||||
const contextUsage = deps.session?.context.tokenUsage ?? 0
|
||||
const contextPercent = Math.round(contextUsage * 100)
|
||||
|
||||
const sessionStats = deps.session?.stats ?? {
|
||||
totalTokens: 0,
|
||||
totalTime: 0,
|
||||
toolCalls: 0,
|
||||
editsApplied: 0,
|
||||
editsRejected: 0,
|
||||
}
|
||||
|
||||
const undoCount = deps.session?.undoStack.length ?? 0
|
||||
|
||||
const message = [
|
||||
"System Status:",
|
||||
` LLM: ${llmStatus}`,
|
||||
` Context: ${String(contextPercent)}% used`,
|
||||
` Auto-apply: ${options.autoApply ? "on" : "off"}`,
|
||||
"",
|
||||
"Session Stats:",
|
||||
` Tokens: ${sessionStats.totalTokens.toLocaleString()}`,
|
||||
` Tool calls: ${String(sessionStats.toolCalls)}`,
|
||||
` Edits: ${String(sessionStats.editsApplied)} applied, ${String(sessionStats.editsRejected)} rejected`,
|
||||
` Undo stack: ${String(undoCount)} entries`,
|
||||
"",
|
||||
"Project:",
|
||||
` Name: ${deps.projectName}`,
|
||||
` Root: ${deps.projectRoot}`,
|
||||
].join("\n")
|
||||
|
||||
return { success: true, message }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createReindexCommand(actions: UseCommandsActions): CommandDefinition {
|
||||
return {
|
||||
name: "reindex",
|
||||
description: "Forces full project reindexation",
|
||||
usage: "/reindex",
|
||||
execute: async (): Promise<CommandResult> => {
|
||||
try {
|
||||
await actions.reindex()
|
||||
return { success: true, message: "Project reindexed successfully." }
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
return { success: false, message: `Reindex failed: ${errorMessage}` }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createEvalCommand(deps: UseCommandsDependencies): CommandDefinition {
|
||||
return {
|
||||
name: "eval",
|
||||
description: "LLM self-check for hallucinations",
|
||||
usage: "/eval",
|
||||
execute: async (): Promise<CommandResult> => {
|
||||
if (!deps.session || deps.session.history.length === 0) {
|
||||
return { success: false, message: "No conversation to evaluate." }
|
||||
}
|
||||
|
||||
const lastAssistantMessage = [...deps.session.history]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant")
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
return { success: false, message: "No assistant response to evaluate." }
|
||||
}
|
||||
|
||||
const evalPrompt = [
|
||||
"Review your last response for potential issues:",
|
||||
"1. Are there any factual errors or hallucinations?",
|
||||
"2. Did you reference files or code that might not exist?",
|
||||
"3. Are there any assumptions that should be verified?",
|
||||
"",
|
||||
"Last response to evaluate:",
|
||||
lastAssistantMessage.content.slice(0, 2000),
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
const response = await deps.llm.chat([
|
||||
{ role: "user", content: evalPrompt, timestamp: Date.now() },
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Self-evaluation:\n${response.content}`,
|
||||
data: { evaluated: lastAssistantMessage.content.slice(0, 100) },
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
return { success: false, message: `Evaluation failed: ${errorMessage}` }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createAutoApplyCommand(
|
||||
actions: UseCommandsActions,
|
||||
options: UseCommandsOptions,
|
||||
): CommandDefinition {
|
||||
return {
|
||||
name: "auto-apply",
|
||||
description: "Toggle auto-apply mode (on/off)",
|
||||
usage: "/auto-apply [on|off]",
|
||||
execute: async (args: string[]): Promise<CommandResult> => {
|
||||
const arg = args[0]?.toLowerCase()
|
||||
|
||||
if (arg === "on") {
|
||||
actions.setAutoApply(true)
|
||||
return Promise.resolve({ success: true, message: "Auto-apply enabled." })
|
||||
}
|
||||
|
||||
if (arg === "off") {
|
||||
actions.setAutoApply(false)
|
||||
return Promise.resolve({ success: true, message: "Auto-apply disabled." })
|
||||
}
|
||||
|
||||
if (!arg) {
|
||||
const current = options.autoApply ? "on" : "off"
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
message: `Auto-apply is currently: ${current}`,
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({ success: false, message: "Usage: /auto-apply [on|off]" })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling slash commands in TUI.
|
||||
*/
|
||||
export function useCommands(
|
||||
deps: UseCommandsDependencies,
|
||||
actions: UseCommandsActions,
|
||||
options: UseCommandsOptions,
|
||||
): UseCommandsReturn {
|
||||
const commands = useMemo((): Map<string, CommandDefinition> => {
|
||||
const map = new Map<string, CommandDefinition>()
|
||||
|
||||
// Register all commands
|
||||
const helpCmd = createHelpCommand(map)
|
||||
map.set("help", helpCmd)
|
||||
map.set("clear", createClearCommand(actions))
|
||||
map.set("undo", createUndoCommand(deps, actions))
|
||||
map.set("sessions", createSessionsCommand(deps))
|
||||
map.set("status", createStatusCommand(deps, options))
|
||||
map.set("reindex", createReindexCommand(actions))
|
||||
map.set("eval", createEvalCommand(deps))
|
||||
map.set("auto-apply", createAutoApplyCommand(actions, options))
|
||||
|
||||
return map
|
||||
}, [deps, actions, options])
|
||||
|
||||
const isCommand = useCallback((input: string): boolean => {
|
||||
return input.trim().startsWith("/")
|
||||
}, [])
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (input: string): Promise<CommandResult | null> => {
|
||||
const parsed = parseCommand(input)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const command = commands.get(parsed.command)
|
||||
if (!command) {
|
||||
const available = Array.from(commands.keys()).join(", ")
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown command: /${parsed.command}\nAvailable: ${available}`,
|
||||
}
|
||||
}
|
||||
|
||||
return command.execute(parsed.args)
|
||||
},
|
||||
[commands],
|
||||
)
|
||||
|
||||
const getCommands = useCallback((): CommandDefinition[] => {
|
||||
return Array.from(commands.values())
|
||||
}, [commands])
|
||||
|
||||
return {
|
||||
executeCommand,
|
||||
isCommand,
|
||||
getCommands,
|
||||
}
|
||||
}
|
||||
117
packages/ipuaro/tests/unit/cli/commands/init.test.ts
Normal file
117
packages/ipuaro/tests/unit/cli/commands/init.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
import { executeInit } from "../../../../src/cli/commands/init.js"
|
||||
|
||||
vi.mock("node:fs/promises")
|
||||
|
||||
describe("executeInit", () => {
|
||||
const testPath = "/test/project"
|
||||
const configPath = path.join(testPath, ".ipuaro.json")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should create .ipuaro.json file successfully", async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||
|
||||
const result = await executeInit(testPath)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.filePath).toBe(configPath)
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
configPath,
|
||||
expect.stringContaining('"redis"'),
|
||||
"utf-8",
|
||||
)
|
||||
})
|
||||
|
||||
it("should skip existing file without force option", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined)
|
||||
|
||||
const result = await executeInit(testPath)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.skipped).toBe(true)
|
||||
expect(fs.writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should overwrite existing file with force option", async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||
|
||||
const result = await executeInit(testPath, { force: true })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.skipped).toBeUndefined()
|
||||
expect(fs.writeFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle write errors", async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Permission denied"))
|
||||
|
||||
const result = await executeInit(testPath)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Permission denied")
|
||||
})
|
||||
|
||||
it("should create parent directories if needed", async () => {
|
||||
vi.mocked(fs.access)
|
||||
.mockRejectedValueOnce(new Error("ENOENT"))
|
||||
.mockRejectedValueOnce(new Error("ENOENT"))
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||
|
||||
const result = await executeInit(testPath)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true })
|
||||
})
|
||||
|
||||
it("should use current directory as default", async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||
|
||||
const result = await executeInit()
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.filePath).toContain(".ipuaro.json")
|
||||
})
|
||||
|
||||
it("should include expected config sections", async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"))
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
|
||||
|
||||
await executeInit(testPath)
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
|
||||
const content = writeCall[1] as string
|
||||
const config = JSON.parse(content) as {
|
||||
redis: unknown
|
||||
llm: unknown
|
||||
edit: unknown
|
||||
}
|
||||
|
||||
expect(config).toHaveProperty("redis")
|
||||
expect(config).toHaveProperty("llm")
|
||||
expect(config).toHaveProperty("edit")
|
||||
expect(config.redis).toHaveProperty("host", "localhost")
|
||||
expect(config.redis).toHaveProperty("port", 6379)
|
||||
expect(config.llm).toHaveProperty("model", "qwen2.5-coder:7b-instruct")
|
||||
expect(config.edit).toHaveProperty("autoApply", false)
|
||||
})
|
||||
})
|
||||
353
packages/ipuaro/tests/unit/cli/commands/onboarding.test.ts
Normal file
353
packages/ipuaro/tests/unit/cli/commands/onboarding.test.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import {
|
||||
checkRedis,
|
||||
checkOllama,
|
||||
checkModel,
|
||||
checkProjectSize,
|
||||
runOnboarding,
|
||||
} from "../../../../src/cli/commands/onboarding.js"
|
||||
import { RedisClient } from "../../../../src/infrastructure/storage/RedisClient.js"
|
||||
import { OllamaClient } from "../../../../src/infrastructure/llm/OllamaClient.js"
|
||||
import { FileScanner } from "../../../../src/infrastructure/indexer/FileScanner.js"
|
||||
|
||||
vi.mock("../../../../src/infrastructure/storage/RedisClient.js")
|
||||
vi.mock("../../../../src/infrastructure/llm/OllamaClient.js")
|
||||
vi.mock("../../../../src/infrastructure/indexer/FileScanner.js")
|
||||
|
||||
describe("onboarding", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("checkRedis", () => {
|
||||
it("should return ok when Redis connects and pings successfully", async () => {
|
||||
const mockConnect = vi.fn().mockResolvedValue(undefined)
|
||||
const mockPing = vi.fn().mockResolvedValue(true)
|
||||
const mockDisconnect = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mocked(RedisClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: mockConnect,
|
||||
ping: mockPing,
|
||||
disconnect: mockDisconnect,
|
||||
}) as unknown as RedisClient,
|
||||
)
|
||||
|
||||
const result = await checkRedis({
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
keyPrefix: "ipuaro:",
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(mockConnect).toHaveBeenCalled()
|
||||
expect(mockPing).toHaveBeenCalled()
|
||||
expect(mockDisconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return error when Redis connection fails", async () => {
|
||||
vi.mocked(RedisClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
}) as unknown as RedisClient,
|
||||
)
|
||||
|
||||
const result = await checkRedis({
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
keyPrefix: "ipuaro:",
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain("Cannot connect to Redis")
|
||||
})
|
||||
|
||||
it("should return error when ping fails", async () => {
|
||||
vi.mocked(RedisClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
ping: vi.fn().mockResolvedValue(false),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
}) as unknown as RedisClient,
|
||||
)
|
||||
|
||||
const result = await checkRedis({
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
db: 0,
|
||||
keyPrefix: "ipuaro:",
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain("Redis ping failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkOllama", () => {
|
||||
it("should return ok when Ollama is available", async () => {
|
||||
vi.mocked(OllamaClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
}) as unknown as OllamaClient,
|
||||
)
|
||||
|
||||
const result = await checkOllama({
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
contextWindow: 128_000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120_000,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return error when Ollama is not available", async () => {
|
||||
vi.mocked(OllamaClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isAvailable: vi.fn().mockResolvedValue(false),
|
||||
}) as unknown as OllamaClient,
|
||||
)
|
||||
|
||||
const result = await checkOllama({
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
contextWindow: 128_000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120_000,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toContain("Cannot connect to Ollama")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkModel", () => {
|
||||
it("should return ok when model is available", async () => {
|
||||
vi.mocked(OllamaClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
hasModel: vi.fn().mockResolvedValue(true),
|
||||
}) as unknown as OllamaClient,
|
||||
)
|
||||
|
||||
const result = await checkModel({
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
contextWindow: 128_000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120_000,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.needsPull).toBe(false)
|
||||
})
|
||||
|
||||
it("should return needsPull when model is not available", async () => {
|
||||
vi.mocked(OllamaClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
hasModel: vi.fn().mockResolvedValue(false),
|
||||
}) as unknown as OllamaClient,
|
||||
)
|
||||
|
||||
const result = await checkModel({
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
contextWindow: 128_000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120_000,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.needsPull).toBe(true)
|
||||
expect(result.error).toContain("not installed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkProjectSize", () => {
|
||||
it("should return ok when file count is within limits", async () => {
|
||||
vi.mocked(FileScanner).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
scanAll: vi.fn().mockResolvedValue(
|
||||
Array.from({ length: 100 }, (_, i) => ({
|
||||
path: `file${String(i)}.ts`,
|
||||
type: "file" as const,
|
||||
size: 1000,
|
||||
lastModified: Date.now(),
|
||||
})),
|
||||
),
|
||||
}) as unknown as FileScanner,
|
||||
)
|
||||
|
||||
const result = await checkProjectSize("/test/path")
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.fileCount).toBe(100)
|
||||
expect(result.warning).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return warning when file count exceeds limit", async () => {
|
||||
vi.mocked(FileScanner).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
scanAll: vi.fn().mockResolvedValue(
|
||||
Array.from({ length: 15000 }, (_, i) => ({
|
||||
path: `file${String(i)}.ts`,
|
||||
type: "file" as const,
|
||||
size: 1000,
|
||||
lastModified: Date.now(),
|
||||
})),
|
||||
),
|
||||
}) as unknown as FileScanner,
|
||||
)
|
||||
|
||||
const result = await checkProjectSize("/test/path", 10_000)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.fileCount).toBe(15000)
|
||||
expect(result.warning).toContain("15")
|
||||
expect(result.warning).toContain("000 files")
|
||||
})
|
||||
|
||||
it("should return error when no files found", async () => {
|
||||
vi.mocked(FileScanner).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
scanAll: vi.fn().mockResolvedValue([]),
|
||||
}) as unknown as FileScanner,
|
||||
)
|
||||
|
||||
const result = await checkProjectSize("/test/path")
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.fileCount).toBe(0)
|
||||
expect(result.warning).toContain("No supported files found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("runOnboarding", () => {
|
||||
it("should return success when all checks pass", async () => {
|
||||
vi.mocked(RedisClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
ping: vi.fn().mockResolvedValue(true),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
}) as unknown as RedisClient,
|
||||
)
|
||||
|
||||
vi.mocked(OllamaClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
hasModel: vi.fn().mockResolvedValue(true),
|
||||
}) as unknown as OllamaClient,
|
||||
)
|
||||
|
||||
vi.mocked(FileScanner).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
scanAll: vi.fn().mockResolvedValue([{ path: "file.ts" }]),
|
||||
}) as unknown as FileScanner,
|
||||
)
|
||||
|
||||
const result = await runOnboarding({
|
||||
redisConfig: { host: "localhost", port: 6379, db: 0, keyPrefix: "ipuaro:" },
|
||||
llmConfig: {
|
||||
model: "test",
|
||||
contextWindow: 128_000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120_000,
|
||||
},
|
||||
projectPath: "/test/path",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.redisOk).toBe(true)
|
||||
expect(result.ollamaOk).toBe(true)
|
||||
expect(result.modelOk).toBe(true)
|
||||
expect(result.projectOk).toBe(true)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return failure when Redis fails", async () => {
|
||||
vi.mocked(RedisClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
connect: vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
}) as unknown as RedisClient,
|
||||
)
|
||||
|
||||
vi.mocked(OllamaClient).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
isAvailable: vi.fn().mockResolvedValue(true),
|
||||
hasModel: vi.fn().mockResolvedValue(true),
|
||||
}) as unknown as OllamaClient,
|
||||
)
|
||||
|
||||
vi.mocked(FileScanner).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
scanAll: vi.fn().mockResolvedValue([{ path: "file.ts" }]),
|
||||
}) as unknown as FileScanner,
|
||||
)
|
||||
|
||||
const result = await runOnboarding({
|
||||
redisConfig: { host: "localhost", port: 6379, db: 0, keyPrefix: "ipuaro:" },
|
||||
llmConfig: {
|
||||
model: "test",
|
||||
contextWindow: 128_000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120_000,
|
||||
},
|
||||
projectPath: "/test/path",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.redisOk).toBe(false)
|
||||
expect(result.errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should skip checks when skip options are set", async () => {
|
||||
const result = await runOnboarding({
|
||||
redisConfig: { host: "localhost", port: 6379, db: 0, keyPrefix: "ipuaro:" },
|
||||
llmConfig: {
|
||||
model: "test",
|
||||
contextWindow: 128_000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120_000,
|
||||
},
|
||||
projectPath: "/test/path",
|
||||
skipRedis: true,
|
||||
skipOllama: true,
|
||||
skipModel: true,
|
||||
skipProject: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.redisOk).toBe(true)
|
||||
expect(result.ollamaOk).toBe(true)
|
||||
expect(result.modelOk).toBe(true)
|
||||
expect(result.projectOk).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
111
packages/ipuaro/tests/unit/cli/commands/tools-setup.test.ts
Normal file
111
packages/ipuaro/tests/unit/cli/commands/tools-setup.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { registerAllTools } from "../../../../src/cli/commands/tools-setup.js"
|
||||
import { ToolRegistry } from "../../../../src/infrastructure/tools/registry.js"
|
||||
|
||||
describe("registerAllTools", () => {
|
||||
it("should register all 18 tools", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
expect(registry.size).toBe(18)
|
||||
})
|
||||
|
||||
it("should register all read tools", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
expect(registry.has("get_lines")).toBe(true)
|
||||
expect(registry.has("get_function")).toBe(true)
|
||||
expect(registry.has("get_class")).toBe(true)
|
||||
expect(registry.has("get_structure")).toBe(true)
|
||||
})
|
||||
|
||||
it("should register all edit tools", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
expect(registry.has("edit_lines")).toBe(true)
|
||||
expect(registry.has("create_file")).toBe(true)
|
||||
expect(registry.has("delete_file")).toBe(true)
|
||||
})
|
||||
|
||||
it("should register all search tools", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
expect(registry.has("find_references")).toBe(true)
|
||||
expect(registry.has("find_definition")).toBe(true)
|
||||
})
|
||||
|
||||
it("should register all analysis tools", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
expect(registry.has("get_dependencies")).toBe(true)
|
||||
expect(registry.has("get_dependents")).toBe(true)
|
||||
expect(registry.has("get_complexity")).toBe(true)
|
||||
expect(registry.has("get_todos")).toBe(true)
|
||||
})
|
||||
|
||||
it("should register all git tools", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
expect(registry.has("git_status")).toBe(true)
|
||||
expect(registry.has("git_diff")).toBe(true)
|
||||
expect(registry.has("git_commit")).toBe(true)
|
||||
})
|
||||
|
||||
it("should register all run tools", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
expect(registry.has("run_command")).toBe(true)
|
||||
expect(registry.has("run_tests")).toBe(true)
|
||||
})
|
||||
|
||||
it("should register tools with correct categories", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
const readTools = registry.getByCategory("read")
|
||||
const editTools = registry.getByCategory("edit")
|
||||
const searchTools = registry.getByCategory("search")
|
||||
const analysisTools = registry.getByCategory("analysis")
|
||||
const gitTools = registry.getByCategory("git")
|
||||
const runTools = registry.getByCategory("run")
|
||||
|
||||
expect(readTools.length).toBe(4)
|
||||
expect(editTools.length).toBe(3)
|
||||
expect(searchTools.length).toBe(2)
|
||||
expect(analysisTools.length).toBe(4)
|
||||
expect(gitTools.length).toBe(3)
|
||||
expect(runTools.length).toBe(2)
|
||||
})
|
||||
|
||||
it("should register tools with requiresConfirmation flag", () => {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
registerAllTools(registry)
|
||||
|
||||
const confirmationTools = registry.getConfirmationTools()
|
||||
const safeTools = registry.getSafeTools()
|
||||
|
||||
expect(confirmationTools.length).toBeGreaterThan(0)
|
||||
expect(safeTools.length).toBeGreaterThan(0)
|
||||
|
||||
const confirmNames = confirmationTools.map((t) => t.name)
|
||||
expect(confirmNames).toContain("edit_lines")
|
||||
expect(confirmNames).toContain("create_file")
|
||||
expect(confirmNames).toContain("delete_file")
|
||||
expect(confirmNames).toContain("git_commit")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import * as path from "node:path"
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as os from "node:os"
|
||||
import {
|
||||
PathValidator,
|
||||
createPathValidator,
|
||||
validatePath,
|
||||
} from "../../../../src/infrastructure/security/PathValidator.js"
|
||||
|
||||
describe("PathValidator", () => {
|
||||
let validator: PathValidator
|
||||
let tempDir: string
|
||||
let projectRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pathvalidator-test-"))
|
||||
projectRoot = path.join(tempDir, "project")
|
||||
await fs.mkdir(projectRoot)
|
||||
validator = new PathValidator(projectRoot)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should resolve project root to absolute path", () => {
|
||||
const relativeValidator = new PathValidator("./project")
|
||||
expect(relativeValidator.getProjectRoot()).toBe(path.resolve("./project"))
|
||||
})
|
||||
|
||||
it("should store project root", () => {
|
||||
expect(validator.getProjectRoot()).toBe(projectRoot)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateSync", () => {
|
||||
it("should validate relative path within project", () => {
|
||||
const result = validator.validateSync("src/file.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
expect(result.absolutePath).toBe(path.join(projectRoot, "src/file.ts"))
|
||||
expect(result.relativePath).toBe(path.join("src", "file.ts"))
|
||||
})
|
||||
|
||||
it("should validate nested relative paths", () => {
|
||||
const result = validator.validateSync("src/components/Button.tsx")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should validate root level files", () => {
|
||||
const result = validator.validateSync("package.json")
|
||||
expect(result.status).toBe("valid")
|
||||
expect(result.relativePath).toBe("package.json")
|
||||
})
|
||||
|
||||
it("should reject empty path", () => {
|
||||
const result = validator.validateSync("")
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path is empty")
|
||||
})
|
||||
|
||||
it("should reject whitespace-only path", () => {
|
||||
const result = validator.validateSync(" ")
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path is empty")
|
||||
})
|
||||
|
||||
it("should reject path with .. traversal", () => {
|
||||
const result = validator.validateSync("../outside")
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should reject path with embedded .. traversal", () => {
|
||||
const result = validator.validateSync("src/../../../etc/passwd")
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should reject path starting with tilde", () => {
|
||||
const result = validator.validateSync("~/secret/file")
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should reject absolute path outside project", () => {
|
||||
const result = validator.validateSync("/etc/passwd")
|
||||
expect(result.status).toBe("outside_project")
|
||||
expect(result.reason).toBe("Path is outside project root")
|
||||
})
|
||||
|
||||
it("should accept absolute path inside project", () => {
|
||||
const absoluteInside = path.join(projectRoot, "src/file.ts")
|
||||
const result = validator.validateSync(absoluteInside)
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should trim whitespace from path", () => {
|
||||
const result = validator.validateSync(" src/file.ts ")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle Windows-style backslashes", () => {
|
||||
const result = validator.validateSync("src\\components\\file.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should reject path that resolves outside via symlink-like patterns", () => {
|
||||
const result = validator.validateSync("src/./../../etc")
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path contains traversal patterns")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validate (async)", () => {
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(path.join(projectRoot, "src"), { recursive: true })
|
||||
await fs.writeFile(path.join(projectRoot, "src/file.ts"), "// content")
|
||||
await fs.mkdir(path.join(projectRoot, "dist"), { recursive: true })
|
||||
})
|
||||
|
||||
it("should validate existing file", async () => {
|
||||
const result = await validator.validate("src/file.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should reject non-existent file by default", async () => {
|
||||
const result = await validator.validate("src/nonexistent.ts")
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path does not exist")
|
||||
})
|
||||
|
||||
it("should allow non-existent file with allowNonExistent option", async () => {
|
||||
const result = await validator.validate("src/newfile.ts", { allowNonExistent: true })
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should validate directory when requireDirectory is true", async () => {
|
||||
const result = await validator.validate("src", { requireDirectory: true })
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should reject file when requireDirectory is true", async () => {
|
||||
const result = await validator.validate("src/file.ts", { requireDirectory: true })
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path is not a directory")
|
||||
})
|
||||
|
||||
it("should validate file when requireFile is true", async () => {
|
||||
const result = await validator.validate("src/file.ts", { requireFile: true })
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should reject directory when requireFile is true", async () => {
|
||||
const result = await validator.validate("src", { requireFile: true })
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path is not a file")
|
||||
})
|
||||
|
||||
it("should handle permission errors gracefully", async () => {
|
||||
const result = await validator.validate("src/../../../root/secret")
|
||||
expect(result.status).toBe("invalid")
|
||||
})
|
||||
|
||||
it("should still check traversal before existence", async () => {
|
||||
const result = await validator.validate("../outside", { allowNonExistent: true })
|
||||
expect(result.status).toBe("invalid")
|
||||
expect(result.reason).toBe("Path contains traversal patterns")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isWithin", () => {
|
||||
it("should return true for path within project", () => {
|
||||
expect(validator.isWithin("src/file.ts")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for project root itself", () => {
|
||||
expect(validator.isWithin(".")).toBe(true)
|
||||
expect(validator.isWithin("")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for path outside project", () => {
|
||||
expect(validator.isWithin("/etc/passwd")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for traversal path", () => {
|
||||
expect(validator.isWithin("../outside")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for empty path", () => {
|
||||
expect(validator.isWithin("")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for tilde path", () => {
|
||||
expect(validator.isWithin("~/file")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolve", () => {
|
||||
it("should resolve valid relative path to absolute", () => {
|
||||
const result = validator.resolve("src/file.ts")
|
||||
expect(result).toBe(path.join(projectRoot, "src/file.ts"))
|
||||
})
|
||||
|
||||
it("should return null for invalid path", () => {
|
||||
expect(validator.resolve("../outside")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty path", () => {
|
||||
expect(validator.resolve("")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for path outside project", () => {
|
||||
expect(validator.resolve("/etc/passwd")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("relativize", () => {
|
||||
it("should return relative path for valid input", () => {
|
||||
const result = validator.relativize("src/file.ts")
|
||||
expect(result).toBe(path.join("src", "file.ts"))
|
||||
})
|
||||
|
||||
it("should handle absolute path within project", () => {
|
||||
const absolutePath = path.join(projectRoot, "src/file.ts")
|
||||
const result = validator.relativize(absolutePath)
|
||||
expect(result).toBe(path.join("src", "file.ts"))
|
||||
})
|
||||
|
||||
it("should return null for path outside project", () => {
|
||||
expect(validator.relativize("/etc/passwd")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for traversal path", () => {
|
||||
expect(validator.relativize("../outside")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle path with multiple slashes", () => {
|
||||
const result = validator.validateSync("src///file.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle path with dots in filename", () => {
|
||||
const result = validator.validateSync("src/file.test.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle hidden files", () => {
|
||||
const result = validator.validateSync(".gitignore")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle hidden directories", () => {
|
||||
const result = validator.validateSync(".github/workflows/ci.yml")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle single dot current directory", () => {
|
||||
const result = validator.validateSync("./src/file.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle project root as path", () => {
|
||||
const result = validator.validateSync(projectRoot)
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle unicode characters in path", () => {
|
||||
const result = validator.validateSync("src/файл.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
|
||||
it("should handle spaces in path", () => {
|
||||
const result = validator.validateSync("src/my file.ts")
|
||||
expect(result.status).toBe("valid")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createPathValidator", () => {
|
||||
it("should create PathValidator instance", () => {
|
||||
const validator = createPathValidator("/tmp/project")
|
||||
expect(validator).toBeInstanceOf(PathValidator)
|
||||
expect(validator.getProjectRoot()).toBe("/tmp/project")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validatePath", () => {
|
||||
let tempDir: string
|
||||
let projectRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "validatepath-test-"))
|
||||
projectRoot = path.join(tempDir, "project")
|
||||
await fs.mkdir(projectRoot)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should return true for valid path", () => {
|
||||
expect(validatePath("src/file.ts", projectRoot)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for traversal path", () => {
|
||||
expect(validatePath("../outside", projectRoot)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for path outside project", () => {
|
||||
expect(validatePath("/etc/passwd", projectRoot)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for empty path", () => {
|
||||
expect(validatePath("", projectRoot)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -224,7 +224,7 @@ describe("CreateFileTool", () => {
|
||||
const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
expect(result.error).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should return error if file already exists", async () => {
|
||||
|
||||
@@ -189,7 +189,7 @@ describe("DeleteFileTool", () => {
|
||||
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
expect(result.error).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should return error if file does not exist", async () => {
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("EditLinesTool", () => {
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
expect(result.error).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should return error when start exceeds file length", async () => {
|
||||
|
||||
@@ -271,7 +271,7 @@ describe("GetClassTool", () => {
|
||||
const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
expect(result.error).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should handle class with no extends", async () => {
|
||||
|
||||
@@ -229,7 +229,7 @@ describe("GetFunctionTool", () => {
|
||||
const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
expect(result.error).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should pad line numbers correctly for large files", async () => {
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("GetLinesTool", () => {
|
||||
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
expect(result.error).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should return error when file not found", async () => {
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("GetStructureTool", () => {
|
||||
const result = await tool.execute({ path: "../outside" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
expect(result.error).toBe("Path contains traversal patterns")
|
||||
})
|
||||
|
||||
it("should return error for non-directory path", async () => {
|
||||
|
||||
301
packages/ipuaro/tests/unit/tui/hooks/useCommands.test.ts
Normal file
301
packages/ipuaro/tests/unit/tui/hooks/useCommands.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Tests for useCommands hook.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
parseCommand,
|
||||
type UseCommandsDependencies,
|
||||
type UseCommandsActions,
|
||||
type UseCommandsOptions,
|
||||
type CommandResult,
|
||||
type CommandDefinition,
|
||||
} from "../../../../src/tui/hooks/useCommands.js"
|
||||
|
||||
describe("useCommands", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("module exports", () => {
|
||||
it("should export useCommands function", async () => {
|
||||
const mod = await import("../../../../src/tui/hooks/useCommands.js")
|
||||
expect(mod.useCommands).toBeDefined()
|
||||
expect(typeof mod.useCommands).toBe("function")
|
||||
})
|
||||
|
||||
it("should export parseCommand function", async () => {
|
||||
const mod = await import("../../../../src/tui/hooks/useCommands.js")
|
||||
expect(mod.parseCommand).toBeDefined()
|
||||
expect(typeof mod.parseCommand).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseCommand", () => {
|
||||
it("should parse simple command", () => {
|
||||
const result = parseCommand("/help")
|
||||
expect(result).toEqual({ command: "help", args: [] })
|
||||
})
|
||||
|
||||
it("should parse command with single argument", () => {
|
||||
const result = parseCommand("/auto-apply on")
|
||||
expect(result).toEqual({ command: "auto-apply", args: ["on"] })
|
||||
})
|
||||
|
||||
it("should parse command with multiple arguments", () => {
|
||||
const result = parseCommand("/sessions load abc123")
|
||||
expect(result).toEqual({ command: "sessions", args: ["load", "abc123"] })
|
||||
})
|
||||
|
||||
it("should handle leading whitespace", () => {
|
||||
const result = parseCommand(" /status")
|
||||
expect(result).toEqual({ command: "status", args: [] })
|
||||
})
|
||||
|
||||
it("should handle trailing whitespace", () => {
|
||||
const result = parseCommand("/help ")
|
||||
expect(result).toEqual({ command: "help", args: [] })
|
||||
})
|
||||
|
||||
it("should handle multiple spaces between args", () => {
|
||||
const result = parseCommand("/sessions load id123")
|
||||
expect(result).toEqual({ command: "sessions", args: ["load", "id123"] })
|
||||
})
|
||||
|
||||
it("should convert command to lowercase", () => {
|
||||
const result = parseCommand("/HELP")
|
||||
expect(result).toEqual({ command: "help", args: [] })
|
||||
})
|
||||
|
||||
it("should convert mixed case command to lowercase", () => {
|
||||
const result = parseCommand("/Status")
|
||||
expect(result).toEqual({ command: "status", args: [] })
|
||||
})
|
||||
|
||||
it("should return null for non-command input", () => {
|
||||
const result = parseCommand("hello world")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty input", () => {
|
||||
const result = parseCommand("")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for whitespace-only input", () => {
|
||||
const result = parseCommand(" ")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for slash in middle of text", () => {
|
||||
const result = parseCommand("hello /command")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle command with hyphen", () => {
|
||||
const result = parseCommand("/auto-apply")
|
||||
expect(result).toEqual({ command: "auto-apply", args: [] })
|
||||
})
|
||||
|
||||
it("should preserve argument case", () => {
|
||||
const result = parseCommand("/sessions load SessionID123")
|
||||
expect(result).toEqual({ command: "sessions", args: ["load", "SessionID123"] })
|
||||
})
|
||||
|
||||
it("should handle just slash", () => {
|
||||
const result = parseCommand("/")
|
||||
expect(result).toEqual({ command: "", args: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("UseCommandsDependencies interface", () => {
|
||||
it("should require session", () => {
|
||||
const deps: Partial<UseCommandsDependencies> = {
|
||||
session: null,
|
||||
}
|
||||
expect(deps.session).toBeNull()
|
||||
})
|
||||
|
||||
it("should require sessionStorage", () => {
|
||||
const deps: Partial<UseCommandsDependencies> = {
|
||||
sessionStorage: {} as UseCommandsDependencies["sessionStorage"],
|
||||
}
|
||||
expect(deps.sessionStorage).toBeDefined()
|
||||
})
|
||||
|
||||
it("should require storage", () => {
|
||||
const deps: Partial<UseCommandsDependencies> = {
|
||||
storage: {} as UseCommandsDependencies["storage"],
|
||||
}
|
||||
expect(deps.storage).toBeDefined()
|
||||
})
|
||||
|
||||
it("should require llm", () => {
|
||||
const deps: Partial<UseCommandsDependencies> = {
|
||||
llm: {} as UseCommandsDependencies["llm"],
|
||||
}
|
||||
expect(deps.llm).toBeDefined()
|
||||
})
|
||||
|
||||
it("should require tools", () => {
|
||||
const deps: Partial<UseCommandsDependencies> = {
|
||||
tools: {} as UseCommandsDependencies["tools"],
|
||||
}
|
||||
expect(deps.tools).toBeDefined()
|
||||
})
|
||||
|
||||
it("should require projectRoot", () => {
|
||||
const deps: Partial<UseCommandsDependencies> = {
|
||||
projectRoot: "/path/to/project",
|
||||
}
|
||||
expect(deps.projectRoot).toBe("/path/to/project")
|
||||
})
|
||||
|
||||
it("should require projectName", () => {
|
||||
const deps: Partial<UseCommandsDependencies> = {
|
||||
projectName: "test-project",
|
||||
}
|
||||
expect(deps.projectName).toBe("test-project")
|
||||
})
|
||||
})
|
||||
|
||||
describe("UseCommandsActions interface", () => {
|
||||
it("should require clearHistory", () => {
|
||||
const actions: Partial<UseCommandsActions> = {
|
||||
clearHistory: vi.fn(),
|
||||
}
|
||||
expect(actions.clearHistory).toBeDefined()
|
||||
})
|
||||
|
||||
it("should require undo", () => {
|
||||
const actions: Partial<UseCommandsActions> = {
|
||||
undo: vi.fn().mockResolvedValue(true),
|
||||
}
|
||||
expect(actions.undo).toBeDefined()
|
||||
})
|
||||
|
||||
it("should require setAutoApply", () => {
|
||||
const actions: Partial<UseCommandsActions> = {
|
||||
setAutoApply: vi.fn(),
|
||||
}
|
||||
expect(actions.setAutoApply).toBeDefined()
|
||||
})
|
||||
|
||||
it("should require reindex", () => {
|
||||
const actions: Partial<UseCommandsActions> = {
|
||||
reindex: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
expect(actions.reindex).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("UseCommandsOptions interface", () => {
|
||||
it("should require autoApply", () => {
|
||||
const options: UseCommandsOptions = {
|
||||
autoApply: true,
|
||||
}
|
||||
expect(options.autoApply).toBe(true)
|
||||
})
|
||||
|
||||
it("should accept false for autoApply", () => {
|
||||
const options: UseCommandsOptions = {
|
||||
autoApply: false,
|
||||
}
|
||||
expect(options.autoApply).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("CommandResult interface", () => {
|
||||
it("should have success and message", () => {
|
||||
const result: CommandResult = {
|
||||
success: true,
|
||||
message: "Command executed",
|
||||
}
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("Command executed")
|
||||
})
|
||||
|
||||
it("should accept optional data", () => {
|
||||
const result: CommandResult = {
|
||||
success: true,
|
||||
message: "Command executed",
|
||||
data: { foo: "bar" },
|
||||
}
|
||||
expect(result.data).toEqual({ foo: "bar" })
|
||||
})
|
||||
|
||||
it("should represent failure", () => {
|
||||
const result: CommandResult = {
|
||||
success: false,
|
||||
message: "Command failed",
|
||||
}
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("CommandDefinition interface", () => {
|
||||
it("should have name and description", () => {
|
||||
const def: CommandDefinition = {
|
||||
name: "test",
|
||||
description: "Test command",
|
||||
usage: "/test [args]",
|
||||
execute: async () => ({ success: true, message: "ok" }),
|
||||
}
|
||||
expect(def.name).toBe("test")
|
||||
expect(def.description).toBe("Test command")
|
||||
})
|
||||
|
||||
it("should have usage string", () => {
|
||||
const def: CommandDefinition = {
|
||||
name: "help",
|
||||
description: "Shows help",
|
||||
usage: "/help",
|
||||
execute: async () => ({ success: true, message: "ok" }),
|
||||
}
|
||||
expect(def.usage).toBe("/help")
|
||||
})
|
||||
|
||||
it("should have async execute function", async () => {
|
||||
const def: CommandDefinition = {
|
||||
name: "test",
|
||||
description: "Test",
|
||||
usage: "/test",
|
||||
execute: async (args) => ({
|
||||
success: true,
|
||||
message: `Args: ${args.join(", ")}`,
|
||||
}),
|
||||
}
|
||||
const result = await def.execute(["arg1", "arg2"])
|
||||
expect(result.message).toBe("Args: arg1, arg2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("UseCommandsReturn interface", () => {
|
||||
it("should define expected return shape", () => {
|
||||
const expectedKeys = ["executeCommand", "isCommand", "getCommands"]
|
||||
|
||||
expectedKeys.forEach((key) => {
|
||||
expect(key).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("command names", () => {
|
||||
it("should define all 8 commands", () => {
|
||||
const expectedCommands = [
|
||||
"help",
|
||||
"clear",
|
||||
"undo",
|
||||
"sessions",
|
||||
"status",
|
||||
"reindex",
|
||||
"eval",
|
||||
"auto-apply",
|
||||
]
|
||||
|
||||
expectedCommands.forEach((cmd) => {
|
||||
expect(cmd).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
||||
"src/**/*.test.ts",
|
||||
"src/tui/**/*.ts",
|
||||
"src/tui/**/*.tsx",
|
||||
"src/cli/**/*.ts",
|
||||
],
|
||||
thresholds: {
|
||||
lines: 95,
|
||||
|
||||
Reference in New Issue
Block a user