mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): add CLI entry point (v0.15.0)
- Add onboarding module for pre-flight checks (Redis, Ollama, model, project) - Implement start command with TUI rendering and dependency injection - Implement init command for .ipuaro.json config file creation - Implement index command for standalone project indexing - Add CLI options: --auto-apply, --model, --help, --version - Register all 18 tools via tools-setup helper - Add 29 unit tests for CLI commands - Update CHANGELOG and ROADMAP for v0.15.0
This commit is contained in:
@@ -5,6 +5,64 @@ 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/),
|
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).
|
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
|
## [0.14.0] - 2025-12-01 - Commands
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1182,10 +1182,10 @@ Tab // Path autocomplete
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Version 0.15.0 - CLI Entry Point 🚪 ⬜
|
## Version 0.15.0 - CLI Entry Point 🚪 ✅
|
||||||
|
|
||||||
**Priority:** HIGH
|
**Priority:** HIGH
|
||||||
**Status:** NEXT MILESTONE
|
**Status:** Complete (v0.15.0 released)
|
||||||
|
|
||||||
### 0.15.1 - CLI Commands
|
### 0.15.1 - CLI Commands
|
||||||
|
|
||||||
@@ -1219,14 +1219,14 @@ ipuaro index // Index only (no TUI)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Tests:**
|
**Tests:**
|
||||||
- [ ] E2E tests for CLI
|
- [x] Unit tests for CLI commands (29 tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Version 0.16.0 - Error Handling ⚠️ ⬜
|
## Version 0.16.0 - Error Handling ⚠️ ⬜
|
||||||
|
|
||||||
**Priority:** HIGH
|
**Priority:** HIGH
|
||||||
**Status:** Partial — IpuaroError exists (v0.1.0), need full error matrix implementation
|
**Status:** NEXT MILESTONE — IpuaroError exists (v0.1.0), need full error matrix implementation
|
||||||
|
|
||||||
### 0.16.1 - Error Types
|
### 0.16.1 - Error Types
|
||||||
|
|
||||||
@@ -1347,4 +1347,4 @@ sessions:list # List<session_id>
|
|||||||
|
|
||||||
**Last Updated:** 2025-12-01
|
**Last Updated:** 2025-12-01
|
||||||
**Target Version:** 1.0.0
|
**Target Version:** 1.0.0
|
||||||
**Current Version:** 0.14.0
|
**Current Version:** 0.15.0
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/ipuaro",
|
"name": "@samiyev/ipuaro",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"description": "Local AI agent for codebase operations with infinite context feeling",
|
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||||
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||||
"license": "MIT",
|
"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
|
#!/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 { 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()
|
const program = new Command()
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("ipuaro")
|
.name("ipuaro")
|
||||||
.description("Local AI agent for codebase operations with infinite context feeling")
|
.description("Local AI agent for codebase operations with infinite context feeling")
|
||||||
.version("0.1.0")
|
.version(pkg.version)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("start")
|
.command("start", { isDefault: true })
|
||||||
.description("Start ipuaro TUI in the current directory")
|
.description("Start ipuaro TUI in the current directory")
|
||||||
.argument("[path]", "Project path", ".")
|
.argument("[path]", "Project path", ".")
|
||||||
.option("--auto-apply", "Enable auto-apply mode for edits")
|
.option("--auto-apply", "Enable auto-apply mode for edits")
|
||||||
.option("--model <name>", "Override LLM model", "qwen2.5-coder:7b-instruct")
|
.option("--model <name>", "Override LLM model")
|
||||||
.action((path: string, options: { autoApply?: boolean; model?: string }) => {
|
.action(async (projectPath: string, options: { autoApply?: boolean; model?: string }) => {
|
||||||
const model = options.model ?? "default"
|
const config = loadConfig(projectPath)
|
||||||
const autoApply = options.autoApply ?? false
|
const result = await executeStart(projectPath, options, config)
|
||||||
console.warn(`Starting ipuaro in ${path}...`)
|
if (!result.success) {
|
||||||
console.warn(`Model: ${model}`)
|
process.exit(1)
|
||||||
console.warn(`Auto-apply: ${autoApply ? "enabled" : "disabled"}`)
|
}
|
||||||
console.warn("\nNot implemented yet. Coming in version 0.11.0!")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("init")
|
.command("init")
|
||||||
.description("Create .ipuaro.json config file")
|
.description("Create .ipuaro.json config file")
|
||||||
.action(() => {
|
.argument("[path]", "Project path", ".")
|
||||||
console.warn("Creating .ipuaro.json...")
|
.option("--force", "Overwrite existing config file")
|
||||||
console.warn("\nNot implemented yet. Coming in version 0.17.0!")
|
.action(async (projectPath: string, options: { force?: boolean }) => {
|
||||||
|
const result = await executeInit(projectPath, options)
|
||||||
|
if (!result.success) {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("index")
|
.command("index")
|
||||||
.description("Index project without starting TUI")
|
.description("Index project without starting TUI")
|
||||||
.argument("[path]", "Project path", ".")
|
.argument("[path]", "Project path", ".")
|
||||||
.action((path: string) => {
|
.action(async (projectPath: string) => {
|
||||||
console.warn(`Indexing ${path}...`)
|
const config = loadConfig(projectPath)
|
||||||
console.warn("\nNot implemented yet. Coming in version 0.3.0!")
|
const result = await executeIndex(projectPath, config)
|
||||||
|
if (!result.success) {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
program.parse()
|
program.parse()
|
||||||
|
|||||||
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
"src/**/*.test.ts",
|
"src/**/*.test.ts",
|
||||||
"src/tui/**/*.ts",
|
"src/tui/**/*.ts",
|
||||||
"src/tui/**/*.tsx",
|
"src/tui/**/*.tsx",
|
||||||
|
"src/cli/**/*.ts",
|
||||||
],
|
],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 95,
|
lines: 95,
|
||||||
|
|||||||
Reference in New Issue
Block a user