ace-tool
Semantic codebase search, code indexing, and prompt enhancement via standalone CLI. Use when: (1) Semantic code search with natural language queries, (2) Code indexing for remote codebase retrieval, (3) Prompt enhancement with codebase context, (4) Before grep/find/glob operation
Install
npx skills add https://github.com/Dianel555/DSkills/tree/main/skills/ace-tool
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install dianel555-dskills@llmmart
git clone https://github.com/Dianel555/DSkills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole dianel555/dskills collection as a plugin from our marketplace. Git is the plain clone.
README
ACE-Tool CLI
Semantic code search, incremental code indexing, and AI-powered prompt enhancement. MCP-independent CLI for codebase navigation and requirement clarification.
Features
- Code Indexing: Incremental scan, hash (SHA-256), chunk, and upload code blobs to ACE service
- Remote Search: Semantic codebase retrieval via
POST /agents/codebase-retrievalwith local fallback - Prompt Enhancement: AI-powered prompt refinement with interactive web UI
- Cloud Context Injection: All endpoints (old, new, third-party) inject retrieval context when
--project-rootis provided - Multiple Backends: Support for Augment (
new/old), Claude, OpenAI, Gemini, and Codex APIs - Search Context Injection: Optional codebase context injection for third-party endpoints via XML tags
.aceignoreSupport: Project-level ignore patterns merged with.gitignorefor indexing- Local Fallback: Works offline with keyword-based search
Installation
pip install httpx tenacity
Quick Start
Authentication Setup
Recommended: Use session.json (compatible with auggie CLI)
# If you have auggie CLI installed, just login:
auggie login
# This creates ~/.augment/session.json with your credentials
Alternative: Environment Variable (for CI/CD)
export AUGMENT_SESSION_AUTH='{"accessToken":"your-token","tenantURL":"https://api.example.com/"}'
Legacy Method (deprecated but still supported)
export ACE_API_URL="https://your-api-endpoint.com"
export ACE_API_TOKEN="your-token-here"
Basic Usage
# Index project (scan, hash, upload code blobs)
python scripts/ace_cli.py index -p .
# Search codebase (remote retrieval if API configured, else local fallback)
python scripts/ace_cli.py search_context -p . -q "user authentication handler"
# Enhance prompt (opens interactive web UI)
python scripts/ace_cli.py enhance_prompt -p "implement login feature" --project-root .
# Enhance prompt (non-interactive, JSON output)
python scripts/ace_cli.py enhance_prompt --no-interactive -p "implement login feature" --project-root .
# Enhance with codex endpoint
python scripts/ace_cli.py --endpoint codex enhance_prompt -p "implement feature"
# Show configuration (check auth_source to verify authentication method)
python scripts/ace_cli.py get_config
CLI Commands
Indexing
| Command | Description |
|---|---|
index -p <path> |
Index project: scan, hash, chunk, upload blobs |
Search Operations
| Command | Description |
|---|---|
search_context -p <path> -q <query> |
Search codebase with natural language |
Enhancement Operations
| Command | Description |
|---|---|
enhance_prompt -p <prompt> |
Enhance prompt (interactive UI) |
enhance_prompt --no-interactive -p <prompt> |
Enhance prompt (JSON output) |
enhance_prompt -H <history> -p <prompt> |
Enhance with conversation history |
enhance_prompt --project-root <path> -p <prompt> |
Enhance with cloud code context |
Configuration
| Command | Description |
|---|---|
get_config |
Show current configuration |
Global Options
python scripts/ace_cli.py [OPTIONS] <command>
Options:
--endpoint TYPE API endpoint: new, old, claude, openai, gemini, codex
--api-url URL Override API base URL
--token TOKEN Override API token
Endpoint Architecture
Supported Endpoints
| Endpoint | API Path | Default Model | Type | Status |
|---|---|---|---|---|
new |
/prompt-enhancer |
claude-sonnet-4-5 |
Augment | ⚠️ Currently unavailable |
old |
/chat-stream (SSE) |
claude-sonnet-4-5 |
Augment | ⚠️ Currently unavailable |
claude |
/v1/messages |
sonnet-4-6-20250929 |
Third-party | ✅ Available |
openai |
/v1/chat/completions |
gpt-5.4 |
Third-party | ✅ Available |
gemini |
/v1beta/models/{model}:generateContent |
gemini-3-flash-preview |
Third-party | ✅ Available |
codex |
/v1/responses |
gpt-5.4 |
Third-party | ✅ Available |
Note: The official Augment endpoints (
newandold) are currently experiencing service issues. Please use third-party endpoints (Claude, OpenAI, Gemini, or Codex) for prompt enhancement features.
Endpoint Resolution
Priority order (highest wins):
PROMPT_ENHANCER_ENDPOINTenv varACE_ENHANCER_ENDPOINTenv var (legacy fallback)--endpointCLI flag- Default:
new
Codex Endpoint
The codex endpoint routes to OpenAI's Responses API (/v1/responses). It uses the input/output array format instead of the Chat Completions messages format. Response parsing handles output_text content parts, final_answer phase priority, and refusal detection.
export PROMPT_ENHANCER_ENDPOINT=codex
export PROMPT_ENHANCER_BASE_URL=https://api.openai.com
export PROMPT_ENHANCER_TOKEN=sk-...
python scripts/ace_cli.py enhance_prompt -p "implement feature"
URL Construction
All HTTP calls use build_api_url(base_url, path) which handles version prefix deduplication (e.g., https://api.example.com/v1 + /v1/messages → https://api.example.com/v1/messages, not .../v1/v1/messages).
Configuration
Authentication
ACE-Tool supports multiple authentication methods with the following priority:
- Constructor parameters (highest priority, programmatic use only)
~/.augment/session.json(recommended, created byauggie login)AUGMENT_SESSION_AUTH(CI/CD and headless environments)- Legacy
ACE_API_*(deprecated, backward compatibility only)
Method 1: session.json (Recommended)
Use auggie login to create ~/.augment/session.json:
{
"accessToken": "your-token-here",
"tenantURL": "https://api.example.com/",
"scopes": ["email"]
}
Method 2: AUGMENT_SESSION_AUTH (CI/CD)
export AUGMENT_SESSION_AUTH='{"accessToken":"your-token","tenantURL":"https://api.example.com/"}'
Method 3: Legacy Environment Variables (Deprecated)
export ACE_API_URL=https://your-augment-api.com
export ACE_API_TOKEN=your-augment-token
⚠️ Note: ACE_API_URL and ACE_API_TOKEN are deprecated but still supported for backward compatibility. New projects should use session.json or AUGMENT_SESSION_AUTH.
Verify Configuration
Use get_config to check current authentication source:
python scripts/ace_cli.py get_config
# Output includes: "auth_source": "session.json" | "AUGMENT_SESSION_AUTH" | "ACE_API_TOKEN" | "none"
Environment Variables
| Variable | Description |
|---|---|
AUGMENT_SESSION_AUTH |
JSON string with accessToken and tenantURL (new format, recommended for CI/CD) |
ACE_API_URL |
⚠️ Deprecated - Augment API base URL (use session.json or AUGMENT_SESSION_AUTH instead) |
ACE_API_TOKEN |
⚠️ Deprecated - Augment API token (use session.json or AUGMENT_SESSION_AUTH instead) |
PROMPT_ENHANCER_ENDPOINT |
Endpoint type override (takes precedence over ACE_ENHANCER_ENDPOINT) |
ACE_ENHANCER_ENDPOINT |
Legacy endpoint override (fallback) |
PROMPT_ENHANCER_BASE_URL |
Third-party API base URL |
PROMPT_ENHANCER_TOKEN |
Third-party API key |
PROMPT_ENHANCER_MODEL |
Model override for third-party endpoints |
PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT |
Enable search context injection (1, true, yes, on) |
.env File
Create .env in the project root (see .env.example):
# Recommended: Use ~/.augment/session.json (created by `auggie login`)
# or set AUGMENT_SESSION_AUTH for CI/CD
# Legacy format (deprecated)
# ACE_API_URL=https://your-augment-api.com
# ACE_API_TOKEN=your-augment-token
PROMPT_ENHANCER_ENDPOINT=new
# Third-party API (optional)
# PROMPT_ENHANCER_BASE_URL=https://api.anthropic.com
# PROMPT_ENHANCER_TOKEN=your-api-key
# PROMPT_ENHANCER_MODEL=sonnet-4-6-20250929
# Search context injection (optional, for third-party endpoints)
# PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT=1
Search Context Injection
When PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT is enabled and a third-party endpoint is used, the system:
- Searches the codebase via ACE API using the original prompt as query
- Normalizes the result (placeholder if empty, truncates at 12,000 chars)
- Wraps search results in
<codebase_context>XML tags - Wraps the original prompt in
<original_request>XML tags - Sends the combined prompt to the third-party LLM
Requirements:
--project-rootmust be provided (raisesValueErrorotherwise)ACE_API_URLandACE_API_TOKENmust be configured
This is separate from the cloud retrieval context that Augment endpoints (new/old) inject automatically via --project-root.
.aceignore
Place a .aceignore file in the project root to exclude additional patterns from code indexing. Uses the same glob syntax as .gitignore. Patterns from both .gitignore and .aceignore are merged (union). Comments (#) and empty lines are skipped.
# .aceignore example
test_fixtures/
*.generated.ts
large_data/
node_modules/
.*/
logs/
tests/
Indexing Details
The index command performs incremental indexing:
- Scan: Walks project files filtered by extension whitelist, binary blacklist,
.gitignore+.aceignorepatterns (with glob support), andEXCLUDE_PATTERNS - Hash:
SHA-256(path_bytes + content_bytes)per blob - Chunk: Files >800 lines split as
file.py#chunk1of3format - Cache: Incremental via
mtime + sizecheck; stored as.ace-tool/index.json.gz - Hierarchy: Nearest ancestor
.ace-toolcache (lookup stops before home / filesystem root) is reused as the index root; child caches in subtrees covered by the root's scan are deleted after a successful index save - Concurrency: Lock-free tolerate-and-converge — a save interrupted by a concurrent absorption abandons persistence (the next run inherits the ancestor root); absorption skips child caches it cannot delete and retries on the next run
- Self-heal: HTTP 400
unknown blobson remote search triggers a full index rebuild + re-upload, then a single retry - Upload: Batch upload (≤30 blobs, ≤1MB per batch) to
POST /batch-uploadwith retry (429 Retry-After, 5xx exponential backoff, 401/403 abort) - Rollback: Upload failure prevents index save, preserving previous valid state
- Encoding: Multi-encoding detection chain (
utf-8 → gbk → gb18030 → cp1252)
Output Format
All CLI output is JSON:
// Index result
{"total_blobs": 42, "last_indexed": 1234567890.0, "project_root": "."}
// Search result (remote)
{"results": "formatted retrieval text...", "query": "...", "mode": "remote", "blob_count": 42}
// Search result (local fallback)
{"results": [{"file": "src/auth.py", "score": 5}], "query": "...", "mode": "local_fallback"}
// Enhancement result
{"enhanced_prompt": "..."}
// Configuration
{"base_url": "...", "endpoint": "new", "endpoint_effective": "new", "endpoint_env_ready": true, "token_configured": true, "third_party_configured": false, "search_context_injection": false}
// Error
{"error": "message", "status_code": 401}
Project Structure
skills/ace-tool/
├── SKILL.md # Agent instructions
├── README.md # Developer documentation
└── scripts/
├── .env.example # Environment template
├── __init__.py
├── __main__.py # Module entry point
├── ace_cli.py # CLI entry point
├── client.py # API client (search, enhance, retrieval, all endpoints)
├── indexer.py # Code indexer (scan, hash, chunk, upload, .aceignore)
├── templates.py # Prompt templates and constants
├── utils.py # Utilities (encoding detection, content sanitization)
└── web_ui.py # Interactive web UI
Acknowledgments
- Based on missdeer/ace-tool-rs
Skill manifest
ACE-Tool - Semantic Code Search & Prompt Enhancement
High-performance semantic search, code indexing, and AI-powered prompt enhancement. Standalone CLI (no MCP dependency).
Execution Methods
# Prerequisites: pip install httpx tenacity
# Environment: ACE_API_URL, ACE_API_TOKEN (optional for local fallback)
# Index project for remote search (upload code blobs to ACE service)
python scripts/ace_cli.py index -p /path/to/project
# Search codebase with natural language (remote if API configured, else local fallback)
python scripts/ace_cli.py search_context -p /path/to/project -q "function that handles authentication"
# Enhance prompt (interactive mode - default, opens browser)
python scripts/ace_cli.py enhance_prompt -p "implement login feature" -H "User: what auth method?\nAssistant: JWT"
# Enhance prompt (non-interactive, JSON output)
python scripts/ace_cli.py enhance_prompt --no-interactive -p "implement login feature"
# Enhance prompt with project context (enables cloud retrieval for all endpoints)
python scripts/ace_cli.py enhance_prompt -p "implement login feature" --project-root /path/to/project
# Enhance prompt with specific endpoint
python scripts/ace_cli.py --endpoint claude enhance_prompt -p "implement login feature"
# Enhance prompt with codex endpoint
python scripts/ace_cli.py --endpoint codex enhance_prompt -p "implement feature"
# Check configuration
python scripts/ace_cli.py get_config
Tool Routing Policy
Prefer ACE-Tool Over Built-in Tools
| Task | Avoid | Use ACE-Tool CLI |
|---|---|---|
| Find function by purpose | grep "def func" |
search_context -q "function that..." |
| Locate feature code | find . -name "*.py" |
search_context -q "feature description" |
| Clarify requirements | Manual analysis | enhance_prompt -p "requirement" |
| Understand code flow | Multiple grep/read | search_context -q "flow description" |
| Index codebase | N/A | index -p <project_root> |
When to Use Built-in Tools
- Exact string matching (known identifiers)
- File path patterns (known naming conventions)
- Simple text replacement
Command Reference
index
Index project files for remote codebase retrieval. Scans, hashes, chunks large files, and uploads to the ACE batch-upload API. Uses incremental indexing with gzip JSON cache at .ace-tool/index.json.gz. Respects both .gitignore and .aceignore patterns. If a parent directory already has a .ace-tool cache (lookup stops before home / filesystem root), it is reused as the effective index root; child caches in subtrees covered by the root's scan are absorbed (deleted) after a successful index save. Concurrent runs need no locks: a save interrupted by a concurrent absorption abandons persistence (the next run inherits the ancestor root), and absorption skips caches it cannot delete, retrying on the next run. A 400 unknown blobs response during remote search triggers an automatic index rebuild, re-upload, and a single retry.
python scripts/ace_cli.py index -p <project_root>
Options:
-p, --project-root Project root path (required)
search_context
Search codebase using natural language descriptions. Routes to remote API (POST /agents/codebase-retrieval) when configured, with automatic local keyword fallback.
python scripts/ace_cli.py search_context -p <project_root> -q <query>
Options:
-p, --project-root Project root path (required)
-q, --query Natural language query (required)
enhance_prompt
Enhance prompts with codebase context and conversation history. All endpoints inject cloud retrieval context when --project-root is provided. Third-party endpoints additionally support search context injection via PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT.
python scripts/ace_cli.py [--endpoint TYPE] enhance_prompt -p <prompt> [options]
Global Options:
--endpoint Endpoint type: new, old, claude, openai, gemini, codex (default: new)
--api-url Override API base URL
--token Override API token
Command Options:
-p, --prompt Original prompt (required)
-H, --history Conversation history: "User: xxx\nAssistant: yyy"
--history-file File containing conversation history
--project-root Project root path (enables cloud retrieval context)
--no-interactive Disable web UI, output JSON directly
--no-browser Don't auto-open browser, just print URL
--port Port for web server (default: 8765)
get_config
Show current configuration status including endpoint resolution, env readiness, authentication source, and search context injection state.
python scripts/ace_cli.py get_config
Output fields:
base_url- Currently configured API base URLendpoint- Active enhancer endpoint (new/old/claude/openai/gemini/codex)endpoint_effective- Resolved endpoint after env variable resolutionendpoint_env_ready- Whether required endpoint configuration is completetoken_configured- Whether authentication token is setthird_party_configured- Whether third-party endpoint config is completeauth_source- Authentication source:constructor|session.json|AUGMENT_SESSION_AUTH|ACE_API_TOKEN|nonesearch_context_injection- Whether search context injection is enabled
Interactive Enhancement
Default mode opens web UI with actions:
| Button | Action |
|---|---|
| Regenerate | Discard current, generate new enhancement from original prompt |
| Refine | Iteratively improve current version, preserving your edits |
| Use Original | Return the original prompt without enhancement |
| Send Enhanced | Confirm and use the current enhanced prompt |
| Cancel | Abort the enhancement process |
Keyboard Shortcuts: Ctrl+Enter Send | Esc Cancel
Endpoint Architecture
Supported Endpoints
| Endpoint | API Path | Default Model | Auth Header | Status |
|---|---|---|---|---|
new |
/prompt-enhancer |
claude-sonnet-4-5 |
Bearer ACE_API_TOKEN |
⚠️ Currently unavailable |
old |
/chat-stream (SSE) |
claude-sonnet-4-5 |
Bearer ACE_API_TOKEN |
⚠️ Currently unavailable |
claude |
/v1/messages |
sonnet-4-6-20250929 |
x-api-key |
✅ Available |
openai |
/v1/chat/completions |
gpt-5.4 |
Bearer PROMPT_ENHANCER_TOKEN |
✅ Available |
gemini |
/v1beta/models/{model}:generateContent |
gemini-3-flash-preview |
x-goog-api-key |
✅ Available |
codex |
/v1/responses |
gpt-5.4 |
Bearer PROMPT_ENHANCER_TOKEN |
✅ Available |
Note: The official Augment endpoints (
newandold) are currently experiencing service issues. Use third-party endpoints for prompt enhancement.
Endpoint Resolution Order
PROMPT_ENHANCER_ENDPOINT > ACE_ENHANCER_ENDPOINT (legacy) > --endpoint CLI flag > new (default)
Third-Party Endpoints
claude, openai, gemini, codex are third-party endpoints. They require:
PROMPT_ENHANCER_BASE_URL— API base URLPROMPT_ENHANCER_TOKEN— API key/tokenPROMPT_ENHANCER_MODEL— (optional) override default model
Missing configuration raises ValueError immediately (hard error, no silent fallback).
URL Construction
All HTTP calls use build_api_url(base_url, path) which handles /v1/, /v1beta/ version prefix deduplication. No hardcoded f-string URL construction.
Environment Variables
Authentication (Priority Order)
ACE-Tool supports multiple authentication methods with the following priority:
- Constructor parameters (highest priority, programmatic use only)
~/.augment/session.json(recommended, created byauggie login)AUGMENT_SESSION_AUTH(CI/CD and headless environments)- Legacy
ACE_API_*(deprecated, backward compatibility only)
| Variable | Description |
|---|---|
AUGMENT_SESSION_AUTH |
JSON string with accessToken and tenantURL (new format, recommended for CI/CD) |
ACE_API_URL |
⚠️ Deprecated - ACE API base URL (use session.json or AUGMENT_SESSION_AUTH instead) |
ACE_API_TOKEN |
⚠️ Deprecated - ACE API authentication token (use session.json or AUGMENT_SESSION_AUTH instead) |
Recommended: Use ~/.augment/session.json
Create this file via auggie login, or manually with this format:
{
"accessToken": "your-token-here",
"tenantURL": "https://api.example.com/",
"scopes": ["email"]
}
For CI/CD: Use AUGMENT_SESSION_AUTH environment variable
export AUGMENT_SESSION_AUTH='{"accessToken":"token","tenantURL":"https://api.example.com/"}'
Migration Guide: Legacy to New Format
If currently using ACE_API_URL and ACE_API_TOKEN:
- Option A (Recommended): Use
auggie loginto create~/.augment/session.json - Option B: Convert to
AUGMENT_SESSION_AUTH:export AUGMENT_SESSION_AUTH='{"accessToken":"YOUR_ACE_API_TOKEN","tenantURL":"YOUR_ACE_API_URL"}' - Remove old variables (optional, they'll be ignored if new format exists)
Legacy variables continue to work for backward compatibility but are not recommended for new setups.
Endpoint Configuration
| Variable | Description |
|---|---|
PROMPT_ENHANCER_ENDPOINT |
Endpoint override: new, old, claude, openai, gemini, codex |
ACE_ENHANCER_ENDPOINT |
Legacy endpoint override (fallback if PROMPT_ENHANCER_ENDPOINT not set) |
PROMPT_ENHANCER_BASE_URL |
Third-party API base URL (for claude/openai/gemini/codex endpoints) |
PROMPT_ENHANCER_TOKEN |
Third-party API token |
PROMPT_ENHANCER_MODEL |
Override default model for third-party endpoints |
PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT |
Enable search context injection for third-party endpoints (1, true, yes, on) |
Search Context Injection
When PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT is enabled and a third-party endpoint is used, the system automatically:
- Performs a remote codebase search via ACE API using the original prompt as query
- Normalizes the result (placeholder if empty, truncate at 12000 chars if too long)
- Wraps the search results in
<codebase_context>XML tags - Wraps the original prompt in
<original_request>XML tags - Sends the combined prompt to the third-party LLM
Requires --project-root and valid ACE_API_URL/ACE_API_TOKEN. Raises ValueError if project_root is missing when injection is enabled.
.aceignore
Place a .aceignore file in the project root to exclude additional patterns from code indexing (beyond .gitignore). Uses the same glob syntax as .gitignore. Patterns from both files are merged (union). Comments (#) and empty lines are skipped.
# .aceignore example
test_fixtures/
*.generated.ts
large_data/
node_modules/
.*/
logs/
tests/
Workflow
Phase 0: Index Project (once or after major changes)
index -p . # Upload code blobs to ACE
Phase 1: Semantic Search
search_context -p . -q "database connection pooling" # Remote retrieval or local fallback
Phase 2: Prompt Enhancement
enhance_prompt -p "optimize query performance" --project-root . # With cloud context
# Review and refine enhanced prompt
# Use Regenerate/Refine as needed
# Send Enhanced to confirm
Error Handling
{"error": "error message", "status_code": 401}
| Error | Recovery |
|---|---|
| No API configured | Uses local fallback for search_context; returns original for enhance |
| Token invalid (401) | Check API token (logged at ERROR level) |
| Access denied (403) | Token may be disabled (logged at ERROR level) |
| Upload failure | Index rollback to previous state; cached blobs still returned |
| Connection timeout | Retries up to 3 times with exponential backoff |
| No results | Broaden search query |
| Third-party not configured | ValueError raised immediately (no silent fallback) |
| Search context missing project_root | ValueError raised when injection enabled without --project-root |
Anti-Patterns
| Prohibited | Correct |
|---|---|
| Grep before semantic search | Use search_context first |
| Skip prompt enhancement | Use enhance_prompt for complex tasks |
| Ignore conversation history | Include history in enhance_prompt |
| Use exact match for conceptual search | Use natural language query |
| Always use non-interactive mode | Use interactive mode for review |
Skip --project-root for enhance |
Include it for cloud-based code context |
Files (dskills)
-
scripts
-
ace_cli.py 5.4 KB
#!/usr/bin/env python3 """ACE-Tool CLI - Semantic code search and prompt enhancement.""" import argparse import json import sys from pathlib import Path import httpx try: from .client import AceToolClient from .indexer import Indexer from .utils import load_env from .web_ui import run_interactive_enhance except ImportError: from client import AceToolClient from indexer import Indexer from utils import load_env from web_ui import run_interactive_enhance load_env() def cmd_search_context(args): """Handle search_context command.""" client = AceToolClient(args.api_url, args.token, args.endpoint) result = client.search_context(args.project_root, args.query) print(json.dumps(result, indent=2, ensure_ascii=False)) def cmd_enhance_prompt(args): """Handle enhance_prompt command.""" client = AceToolClient(args.api_url, args.token, args.endpoint, args.reasoning_effort) history = args.history if args.history_file: history = Path(args.history_file).read_text(encoding="utf-8") if not args.no_interactive: result = run_interactive_enhance( client, args.prompt, history, args.port, auto_open_browser=not args.no_browser, project_root=args.project_root, ) if result: print(json.dumps({"enhanced_prompt": result}, indent=2, ensure_ascii=False)) else: print(json.dumps({"cancelled": True}), file=sys.stderr) sys.exit(1) else: result = client.enhance_prompt(args.prompt, history, args.project_root) if "error" in result: print(json.dumps(result), file=sys.stderr) sys.exit(1) print(json.dumps(result, indent=2, ensure_ascii=False)) def cmd_get_config(args): """Handle get_config command.""" client = AceToolClient(args.api_url, args.token, args.endpoint) result = client.get_config() print(json.dumps(result, indent=2, ensure_ascii=False)) def cmd_index(args): """Handle index command.""" client = AceToolClient(args.api_url, args.token, args.endpoint) if not client.base_url or not client.token: print( json.dumps( { "error": "No authentication configured for indexing. Set up session.json, AUGMENT_SESSION_AUTH, or ACE_API_URL/ACE_API_TOKEN" } ), file=sys.stderr, ) sys.exit(1) indexer = Indexer(args.project_root, client.base_url, client.token) blob_names = indexer.get_blob_names() result = { "total_blobs": len(blob_names), "last_indexed": indexer._index.last_indexed, "project_root": args.project_root, "effective_root": str(indexer.root), } print(json.dumps(result, indent=2, ensure_ascii=False)) def main(): parser = argparse.ArgumentParser(description="ACE-Tool CLI - Semantic code search and prompt enhancement") parser.add_argument("--api-url", help="API base URL") parser.add_argument("--token", help="API authentication token") parser.add_argument( "--endpoint", choices=["new", "old", "claude", "openai", "gemini", "codex"], help="Enhancer endpoint type", ) subparsers = parser.add_subparsers(dest="command", required=True) p_search = subparsers.add_parser("search_context", help="Search codebase with natural language") p_search.add_argument("-p", "--project-root", required=True, help="Project root path") p_search.add_argument("-q", "--query", required=True, help="Natural language query") p_search.set_defaults(func=cmd_search_context) p_enhance = subparsers.add_parser("enhance_prompt", help="Enhance prompt with context") p_enhance.add_argument("-p", "--prompt", required=True, help="Original prompt") p_enhance.add_argument("-H", "--history", default="", help="Conversation history") p_enhance.add_argument("--history-file", help="File containing conversation history") p_enhance.add_argument("--project-root", help="Project root path (optional)") p_enhance.add_argument( "--reasoning-effort", default=None, metavar="LEVEL", help="Reasoning effort: none/minimal/low/medium/high/xhigh/max (default: none, or PROMPT_ENHANCER_REASONING_EFFORT; empty string omits the parameter)", ) p_enhance.add_argument("--no-interactive", action="store_true", help="Disable web UI, output JSON directly") p_enhance.add_argument("--no-browser", action="store_true", help="Don't auto-open browser, just print URL") p_enhance.add_argument("--port", type=int, default=8765, help="Port for interactive web server (default: 8765)") p_enhance.set_defaults(func=cmd_enhance_prompt) p_config = subparsers.add_parser("get_config", help="Show current configuration") p_config.set_defaults(func=cmd_get_config) p_index = subparsers.add_parser("index", help="Index project for remote search") p_index.add_argument("-p", "--project-root", required=True, help="Project root path") p_index.set_defaults(func=cmd_index) args = parser.parse_args() try: args.func(args) except httpx.HTTPStatusError as e: print(json.dumps({"error": str(e), "status_code": e.response.status_code}), file=sys.stderr) sys.exit(1) except Exception as e: print(json.dumps({"error": str(e)}), file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() -
client.py 29.9 KB
"""ACE-Tool API client for semantic search and prompt enhancement.""" import json import os import re import uuid from pathlib import Path import httpx from tenacity import retry, retry_if_not_exception_type, stop_after_attempt, wait_exponential try: from .indexer import Indexer from .templates import ( DEFAULT_CLAUDE_MODEL, DEFAULT_CODEX_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_MODEL, DEFAULT_OPENAI_MODEL, ENCODING_CHAIN, ENHANCE_PROMPT_TEMPLATE, ENV_ENHANCER_ENDPOINT, ENV_ENHANCER_ENDPOINT_LEGACY, ENV_ENHANCER_INCLUDE_SEARCH_CONTEXT, ENV_ENHANCER_REASONING_EFFORT, EXCLUDE_PATTERNS, ITERATIVE_ENHANCE_TEMPLATE, NO_RELEVANT_CODE_CONTEXT, RETRIEVAL_TIMEOUT, SEARCH_CONTEXT_CHAR_LIMIT, TEXT_EXTENSIONS, USER_AGENT, ) from .utils import ( build_api_url, detect_and_read, get_session_id, is_chinese_text, load_session_auth, parse_chat_history, ) except ImportError: from indexer import Indexer from templates import ( DEFAULT_CLAUDE_MODEL, DEFAULT_CODEX_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_MODEL, DEFAULT_OPENAI_MODEL, ENCODING_CHAIN, ENHANCE_PROMPT_TEMPLATE, ENV_ENHANCER_ENDPOINT, ENV_ENHANCER_ENDPOINT_LEGACY, ENV_ENHANCER_INCLUDE_SEARCH_CONTEXT, ENV_ENHANCER_REASONING_EFFORT, EXCLUDE_PATTERNS, ITERATIVE_ENHANCE_TEMPLATE, NO_RELEVANT_CODE_CONTEXT, RETRIEVAL_TIMEOUT, SEARCH_CONTEXT_CHAR_LIMIT, TEXT_EXTENSIONS, USER_AGENT, ) from utils import ( build_api_url, detect_and_read, get_session_id, is_chinese_text, load_session_auth, parse_chat_history, ) import logging log = logging.getLogger(__name__) _ENHANCED_PROMPT_RE = re.compile( r"<augment-enhanced-prompt(?:\s+[^>]*)?>\s*(.*?)\s*</augment-enhanced-prompt\s*>", re.DOTALL, ) _THIRD_PARTY_ENDPOINTS = frozenset({"claude", "openai", "gemini", "codex"}) _TRUE_ENV_VALUES = frozenset({"1", "true", "yes", "on"}) class AceToolClient: """Client for ACE-Tool API endpoints.""" def __init__( self, base_url: str | None = None, token: str | None = None, endpoint: str | None = None, reasoning_effort: str | None = None, ): # Determine auth source priority: constructor params > session auth if base_url is not None or token is not None: # Constructor params provided (full or partial override) self.auth_source = "constructor" loaded_base_url, loaded_token, _ = load_session_auth() self.base_url = (base_url if base_url is not None else loaded_base_url or "").rstrip("/") self.token = token if token is not None else loaded_token or "" else: # No constructor params, use load_session_auth() loaded_base_url, loaded_token, auth_source = load_session_auth() self.base_url = (loaded_base_url or "").rstrip("/") self.token = loaded_token or "" self.auth_source = auth_source # Endpoint resolution: PROMPT_ENHANCER_ENDPOINT > ACE_ENHANCER_ENDPOINT > constructor arg > "new" env_endpoint = os.getenv(ENV_ENHANCER_ENDPOINT, "") or os.getenv(ENV_ENHANCER_ENDPOINT_LEGACY, "") resolved = env_endpoint or endpoint or "new" self.endpoint = resolved.lower() self.timeout = httpx.Timeout(180.0, connect=30.0) self.third_party_base_url = os.getenv("PROMPT_ENHANCER_BASE_URL", "").rstrip("/") self.third_party_token = os.getenv("PROMPT_ENHANCER_TOKEN", "") self.third_party_model = os.getenv("PROMPT_ENHANCER_MODEL", "") # Reasoning control: default none (enhancement needs no deep reasoning); # empty string sends no reasoning parameter at all. env_val = os.environ.get(ENV_ENHANCER_REASONING_EFFORT) if reasoning_effort is not None: self.reasoning_effort = reasoning_effort.strip().lower() elif env_val is not None: self.reasoning_effort = env_val.strip().lower() else: self.reasoning_effort = "none" def _get_headers(self, use_third_party: bool = False) -> dict: headers = { "Content-Type": "application/json", "User-Agent": USER_AGENT, "x-request-id": str(uuid.uuid4()), "x-request-session-id": get_session_id(), } if use_third_party and self.third_party_token: headers["Authorization"] = f"Bearer {self.third_party_token}" elif self.token: headers["Authorization"] = f"Bearer {self.token}" return headers def _is_third_party(self) -> bool: return self.endpoint in _THIRD_PARTY_ENDPOINTS def _should_include_search_context(self) -> bool: """Check if search context injection is enabled via env var.""" val = os.environ.get(ENV_ENHANCER_INCLUDE_SEARCH_CONTEXT, "").strip().lower() return val in _TRUE_ENV_VALUES @staticmethod def _normalize_search_context(text: str) -> str: """Normalize search context: placeholder for empty, truncate if over limit.""" stripped = text.strip() if not stripped or stripped == NO_RELEVANT_CODE_CONTEXT: return NO_RELEVANT_CODE_CONTEXT if len(stripped) > SEARCH_CONTEXT_CHAR_LIMIT: return stripped[:SEARCH_CONTEXT_CHAR_LIMIT] + "\n\n[codebase_context truncated for length]" return stripped @staticmethod def _build_prompt_with_search_context(original: str, ctx: str) -> str: """Wrap original prompt with codebase context in XML tags.""" return f"<codebase_context>\n{ctx}\n</codebase_context>\n\n<original_request>\n{original}\n</original_request>" def _maybe_inject_search_context(self, endpoint: str, prompt: str, project_root: str | None) -> str: """Inject search context into prompt if enabled and applicable.""" if not self._is_third_party() or not self._should_include_search_context(): return prompt if not project_root: raise ValueError("project_root is required when search context injection is enabled") if not self.base_url or not self.token: raise ValueError( "No authentication configured for search context injection. Set up session.json, AUGMENT_SESSION_AUTH, or ACE_API_URL/ACE_API_TOKEN" ) result = self._remote_search(project_root, prompt) raw_ctx = result.get("results", "") ctx = self._normalize_search_context(raw_ctx) return self._build_prompt_with_search_context(prompt, ctx) def _get_third_party_model(self) -> str: if self.third_party_model: return self.third_party_model return { "claude": DEFAULT_CLAUDE_MODEL, "openai": DEFAULT_OPENAI_MODEL, "gemini": DEFAULT_GEMINI_MODEL, "codex": DEFAULT_CODEX_MODEL, }.get(self.endpoint, DEFAULT_MODEL) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) def search_context(self, project_root: str, query: str) -> dict: """Search codebase: remote via ACE API if available, else local fallback.""" if self.base_url and self.token: try: return self._remote_search(project_root, query) except httpx.HTTPStatusError as e: if e.response.status_code in (401, 403): log.error("Auth failed (%d) for remote search: %s", e.response.status_code, e) else: log.warning("Remote search failed, falling back to local: %s", e) except Exception as e: log.warning("Remote search failed, falling back to local: %s", e) return self._local_search(project_root, query) @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_not_exception_type(ValueError), ) def enhance_prompt( self, prompt: str, conversation_history: str, project_root: str | None = None, ) -> dict: """Enhance prompt with codebase context and conversation history.""" if self._is_third_party(): return self._call_third_party_api(prompt, conversation_history, project_root) if not self.base_url: return {"enhanced_prompt": prompt, "note": "No API configured, returning original"} chat_history = parse_chat_history(conversation_history) if self.endpoint == "old": return self._call_old_endpoint(prompt, chat_history, project_root) return self._call_new_endpoint(prompt, chat_history, project_root) @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_not_exception_type(ValueError), ) def iterative_enhance( self, original_prompt: str, previous_enhanced: str, current_prompt: str, conversation_history: str, project_root: str | None = None, ) -> dict: """Iteratively enhance an already-enhanced prompt, preserving user modifications.""" iterative_prompt = ITERATIVE_ENHANCE_TEMPLATE.format( original_prompt=original_prompt, previous_enhanced=previous_enhanced, current_prompt=current_prompt, ) if self._is_third_party(): return self._call_third_party_api_raw(iterative_prompt, conversation_history, project_root) if not self.base_url: return {"enhanced_prompt": current_prompt, "note": "No API configured, returning current"} chat_history = parse_chat_history(conversation_history) if self.endpoint == "old": return self._call_old_endpoint_raw(iterative_prompt, chat_history, project_root) return self._call_new_endpoint_raw(iterative_prompt, chat_history, project_root) def _call_new_endpoint(self, prompt: str, chat_history: list[dict], project_root: str | None = None) -> dict: """Call /prompt-enhancer endpoint (new).""" context = self._get_retrieval_context(project_root, prompt) enriched_prompt = f"{context}{prompt}" if context else prompt payload = { "nodes": [{"id": 0, "type": 0, "text_node": {"content": enriched_prompt}}], "chat_history": chat_history, "conversation_id": None, "model": DEFAULT_MODEL, "mode": "CHAT", } data = self._post_json( build_api_url(self.base_url, "/prompt-enhancer"), payload, headers=self._get_headers(), ) return {"enhanced_prompt": data.get("text", prompt)} def _call_new_endpoint_raw( self, raw_prompt: str, chat_history: list[dict], project_root: str | None = None ) -> dict: """Call /prompt-enhancer with pre-built prompt.""" context = self._get_retrieval_context(project_root, raw_prompt) enriched_prompt = f"{context}{raw_prompt}" if context else raw_prompt payload = { "nodes": [{"id": 0, "type": 0, "text_node": {"content": enriched_prompt}}], "chat_history": chat_history, "conversation_id": None, "model": DEFAULT_MODEL, "mode": "CHAT", } data = self._post_json( build_api_url(self.base_url, "/prompt-enhancer"), payload, headers=self._get_headers(), ) text = data.get("text", raw_prompt) return {"enhanced_prompt": self._extract_enhanced_prompt(text)} def _build_old_payload( self, message: str, chat_history: list[dict], language_guideline: str, blob_names: list[str] | None = None ) -> dict: """Build payload for old endpoint.""" return { "model": DEFAULT_MODEL, "path": None, "prefix": None, "selected_code": None, "suffix": None, "message": message, "chat_history": chat_history, "lang": None, "blobs": {"checkpoint_id": None, "added_blobs": blob_names or [], "deleted_blobs": []}, "user_guided_blobs": [], "context_code_exchange_request_id": None, "external_source_ids": [], "disable_auto_external_sources": None, "user_guidelines": language_guideline, "workspace_guidelines": "", "feature_detection_flags": {"support_parallel_tool_use": None}, "third_party_override": None, "tool_definitions": [], "nodes": [{"id": 1, "type": 0, "text_node": {"content": message}}], "mode": "CHAT", "agent_memories": None, "persona_type": None, "rules": [], "silent": None, "enable_parallel_tool_use": None, "conversation_id": None, "system_prompt": None, } def _get_blob_names(self, project_root: str | None) -> list[str] | None: """Get blob_names from indexer if API and project_root are available.""" if not project_root or not self.base_url or not self.token: return None try: indexer = Indexer(project_root, self.base_url, self.token) return indexer.get_blob_names() except httpx.HTTPStatusError as e: if e.response.status_code in (401, 403): log.error("Auth failed (%d) getting blob names: %s", e.response.status_code, e) else: log.warning("Failed to get blob names: %s", e) return None except Exception as e: log.warning("Failed to get blob names: %s", e) return None def _get_retrieval_context(self, project_root: str | None, query: str) -> str: """Get cloud retrieval context to inject into prompts for non-old endpoints.""" if not project_root or not self.base_url or not self.token: return "" try: result = self._remote_search(project_root, query) context = result.get("results", "") if context: return f"\n\n<codebase-context>\n{context}\n</codebase-context>\n\n" except httpx.HTTPStatusError as e: if e.response.status_code in (401, 403): log.error("Auth failed (%d) for cloud retrieval: %s", e.response.status_code, e) else: log.warning("Cloud retrieval failed, proceeding without context: %s", e) except Exception as e: log.warning("Cloud retrieval failed, proceeding without context: %s", e) return "" def _call_old_endpoint(self, prompt: str, chat_history: list[dict], project_root: str | None = None) -> dict: """Call /chat-stream endpoint (old, streaming).""" final_prompt = ENHANCE_PROMPT_TEMPLATE.replace("{original_prompt}", prompt) language_guideline = ( "Please respond in Chinese (Simplified Chinese). 请用中文回复。" if is_chinese_text(prompt) else "" ) blob_names = self._get_blob_names(project_root) payload = self._build_old_payload(final_prompt, chat_history, language_guideline, blob_names) with httpx.Client(timeout=self.timeout) as client: resp = client.post( build_api_url(self.base_url, "/chat-stream"), headers=self._get_headers(), json=payload, ) self._check_auth_error(resp.status_code) resp.raise_for_status() raw_text = self._parse_streaming_response(resp.text) enhanced = self._extract_enhanced_prompt(raw_text) enhanced = self._replace_tool_names(enhanced) return {"enhanced_prompt": enhanced} def _call_old_endpoint_raw( self, raw_prompt: str, chat_history: list[dict], project_root: str | None = None ) -> dict: """Call /chat-stream with pre-built prompt.""" language_guideline = ( "Please respond in Chinese (Simplified Chinese). 请用中文回复。" if is_chinese_text(raw_prompt) else "" ) blob_names = self._get_blob_names(project_root) payload = self._build_old_payload(raw_prompt, chat_history, language_guideline, blob_names) with httpx.Client(timeout=self.timeout) as client: resp = client.post( build_api_url(self.base_url, "/chat-stream"), headers=self._get_headers(), json=payload, ) self._check_auth_error(resp.status_code) resp.raise_for_status() raw_text = self._parse_streaming_response(resp.text) enhanced = self._extract_enhanced_prompt(raw_text) enhanced = self._replace_tool_names(enhanced) return {"enhanced_prompt": enhanced} def _parse_streaming_response(self, body: str) -> str: """Parse streaming response from /chat-stream endpoint.""" combined = [] for line in body.split("\n"): line = line.strip() if not line or line.startswith("data:"): line = line[5:].strip() if line.startswith("data:") else "" if not line or line == "[DONE]": continue try: data = json.loads(line) if text := data.get("text"): combined.append(text) except json.JSONDecodeError: continue return "".join(combined) if combined else body def _extract_enhanced_prompt(self, text: str) -> str: """Extract enhanced prompt from XML-like response.""" match = _ENHANCED_PROMPT_RE.search(text) if match: extracted = match.group(1).strip() if extracted: return extracted return text def _replace_tool_names(self, text: str) -> str: """Replace Augment-specific tool names with ace-tool names.""" return text.replace("codebase-retrieval", "search_context").replace("codebase_retrieval", "search_context") def _post_json( self, url: str, payload: dict, *, headers: dict, provider: str = "API", timeout: httpx.Timeout | None = None, ) -> dict: with httpx.Client(timeout=timeout or self.timeout) as client: resp = client.post(url, headers=headers, json=payload) self._check_auth_error(resp.status_code, provider) resp.raise_for_status() return resp.json() def _enhanced_prompt_from_text(self, text: str, fallback: str) -> dict: enhanced = self._extract_enhanced_prompt(text) if text else fallback return {"enhanced_prompt": self._replace_tool_names(enhanced)} def _call_third_party_api(self, prompt: str, conversation_history: str, project_root: str | None = None) -> dict: """Call third-party API (Claude/OpenAI/Gemini/Codex).""" if not self.third_party_base_url or not self.third_party_token: raise ValueError( f"PROMPT_ENHANCER_BASE_URL and PROMPT_ENHANCER_TOKEN required for '{self.endpoint}' endpoint" ) chat_history = parse_chat_history(conversation_history) model = self._get_third_party_model() injected_prompt = self._maybe_inject_search_context(self.endpoint, prompt, project_root) final_prompt = ENHANCE_PROMPT_TEMPLATE.replace("{original_prompt}", injected_prompt) language_hint = "\n\n请用中文回复。" if is_chinese_text(prompt) else "" full_prompt = f"{final_prompt}{language_hint}" return self._dispatch_third_party(full_prompt, chat_history, model) def _call_third_party_api_raw( self, raw_prompt: str, conversation_history: str, project_root: str | None = None ) -> dict: """Call third-party API with pre-built prompt.""" if not self.third_party_base_url or not self.third_party_token: raise ValueError( f"PROMPT_ENHANCER_BASE_URL and PROMPT_ENHANCER_TOKEN required for '{self.endpoint}' endpoint" ) chat_history = parse_chat_history(conversation_history) model = self._get_third_party_model() injected_prompt = self._maybe_inject_search_context(self.endpoint, raw_prompt, project_root) language_hint = "\n\n请用中文回复。" if is_chinese_text(raw_prompt) else "" full_prompt = f"{injected_prompt}{language_hint}" return self._dispatch_third_party(full_prompt, chat_history, model) def _dispatch_third_party(self, prompt: str, chat_history: list[dict], model: str) -> dict: """Dispatch to appropriate third-party API.""" if self.endpoint == "claude": return self._call_claude_api(prompt, chat_history, model) elif self.endpoint == "openai": return self._call_openai_api(prompt, chat_history, model) elif self.endpoint == "gemini": return self._call_gemini_api(prompt, chat_history, model) elif self.endpoint == "codex": return self._call_codex_api(prompt, chat_history, model) return {"error": f"Unknown endpoint: {self.endpoint}"} @staticmethod def _extract_codex_output_text(api_response: dict) -> str: outputs = api_response.get("output", []) final = [o for o in outputs if o.get("type") == "message" and o.get("phase") == "final_answer"] candidates = final or [o for o in outputs if o.get("type") == "message"] text_parts, refusal_parts = [], [] for msg in candidates: for part in msg.get("content") or []: t = part.get("type") if t == "output_text": txt = (part.get("text") or "").strip() if txt: text_parts.append(txt) elif t == "refusal": rf = (part.get("refusal") or "").strip() if rf: refusal_parts.append(rf) if text_parts: return "\n".join(text_parts) if refusal_parts: raise RuntimeError(f"Codex API refusal: {chr(10).join(refusal_parts)}") raise RuntimeError("Codex API returned no output_text content") def _call_codex_api(self, prompt: str, chat_history: list[dict], model: str) -> dict: input_items = [] for msg in chat_history: input_items.append({"role": msg["role"], "content": msg["content"]}) input_items.append({"role": "user", "content": prompt}) payload = {"model": model, "input": input_items} if self.reasoning_effort: payload["reasoning"] = {"effort": self.reasoning_effort} url = build_api_url(self.third_party_base_url, "/v1/responses") data = self._post_json(url, payload, headers=self._get_headers(use_third_party=True), provider="Codex") text = self._extract_codex_output_text(data) return self._enhanced_prompt_from_text(text, prompt) def _call_claude_api(self, prompt: str, chat_history: list[dict], model: str) -> dict: """Call Claude API.""" messages = chat_history + [{"role": "user", "content": prompt}] payload = {"model": model, "max_tokens": 4096, "messages": messages} # ponytail: adaptive only; legacy thinking-enabled/budget models unmapped — # unset effort if such an endpoint rejects adaptive. if self.reasoning_effort: if self.reasoning_effort == "none": payload["thinking"] = {"type": "disabled"} else: payload["thinking"] = {"type": "adaptive"} payload["output_config"] = {"effort": self.reasoning_effort} url = build_api_url(self.third_party_base_url, "/v1/messages") data = self._post_json( url, payload, headers={ "Content-Type": "application/json", "x-api-key": self.third_party_token, "anthropic-version": "2023-06-01", }, provider="Claude", ) text = "".join(c.get("text", "") for c in data.get("content", []) if c.get("type") == "text") return self._enhanced_prompt_from_text(text, prompt) def _call_openai_api(self, prompt: str, chat_history: list[dict], model: str) -> dict: """Call OpenAI API.""" messages = chat_history + [{"role": "user", "content": prompt}] payload = {"model": model, "messages": messages, "max_tokens": 4096} if self.reasoning_effort: payload["reasoning_effort"] = self.reasoning_effort url = build_api_url(self.third_party_base_url, "/v1/chat/completions") data = self._post_json(url, payload, headers=self._get_headers(use_third_party=True), provider="OpenAI") text = data.get("choices", [{}])[0].get("message", {}).get("content", "") return self._enhanced_prompt_from_text(text, prompt) def _call_gemini_api(self, prompt: str, chat_history: list[dict], model: str) -> dict: """Call Gemini API.""" contents = [] for msg in chat_history: role = "user" if msg["role"] == "user" else "model" contents.append({"role": role, "parts": [{"text": msg["content"]}]}) contents.append({"role": "user", "parts": [{"text": prompt}]}) payload = {"contents": contents, "generationConfig": {"maxOutputTokens": 4096}} # Gemini 3 has no "none" thinking level; "minimal" is its lowest. if self.reasoning_effort: level = "minimal" if self.reasoning_effort == "none" else self.reasoning_effort payload["generationConfig"]["thinkingConfig"] = {"thinkingLevel": level} url = build_api_url(self.third_party_base_url, f"/v1beta/models/{model}:generateContent") data = self._post_json( url, payload, headers={"Content-Type": "application/json", "x-goog-api-key": self.third_party_token}, provider="Gemini", ) text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") return self._enhanced_prompt_from_text(text, prompt) def _check_auth_error(self, status: int, provider: str = "API"): if status == 401: raise httpx.HTTPStatusError( f"{provider} token invalid or expired", request=None, response=httpx.Response(status), ) if status == 403: raise httpx.HTTPStatusError( f"{provider} access denied, token may be disabled", request=None, response=httpx.Response(status), ) def _remote_search(self, project_root: str, query: str) -> dict: """Search via ACE codebase-retrieval API.""" indexer = Indexer(project_root, self.base_url, self.token) blob_names = indexer.get_blob_names() url = build_api_url(self.base_url, "/agents/codebase-retrieval") payload = { "information_request": query, "blobs": {"checkpoint_id": None, "added_blobs": blob_names, "deleted_blobs": []}, "dialog": [], "max_output_length": 0, "disable_codebase_retrieval": False, "enable_commit_retrieval": False, } timeout = httpx.Timeout(RETRIEVAL_TIMEOUT, connect=15.0) try: data = self._post_json(url, payload, headers=self._get_headers(), timeout=timeout) except httpx.HTTPStatusError as e: # Server lost blobs the local index marked as uploaded: rebuild, re-upload, retry once. if e.response.status_code != 400 or "unknown blob" not in e.response.text.lower(): raise log.warning("Server reports unknown blobs; rebuilding index and retrying once") blob_names = indexer.force_rebuild() payload["blobs"]["added_blobs"] = blob_names data = self._post_json(url, payload, headers=self._get_headers(), timeout=timeout) return { "results": data.get("formatted_retrieval", ""), "query": query, "mode": "remote", "blob_count": len(blob_names), } def _local_search(self, project_root: str, query: str) -> dict: """Fallback local search using keyword matching.""" results = [] root = Path(project_root) keywords = [w.lower() for w in re.findall(r"\w+", query.lower()) if len(w) > 2] for file_path in root.rglob("*"): if not file_path.is_file(): continue if file_path.suffix.lower() not in TEXT_EXTENSIONS: continue if any(p in EXCLUDE_PATTERNS for p in file_path.parts): continue if any(part.startswith(".") for part in file_path.parts[len(root.parts) :]): continue try: content = detect_and_read(file_path, ENCODING_CHAIN) if content is None: continue content = content.lower() score = sum(1 for kw in keywords if kw in content) if score > 0: results.append({"file": str(file_path.relative_to(root)), "score": score}) except Exception: continue results.sort(key=lambda x: x["score"], reverse=True) return {"results": results[:10], "query": query, "mode": "local_fallback"} def get_config(self) -> dict: """Get current configuration.""" return { "base_url": self.base_url or "(not configured)", "endpoint": self.endpoint, "endpoint_effective": self.endpoint, "endpoint_env_ready": bool(self.third_party_base_url and self.third_party_token) if self._is_third_party() else bool(self.base_url and self.token), "token_configured": bool(self.token), "third_party_configured": bool(self.third_party_base_url and self.third_party_token), "auth_source": self.auth_source, "search_context_injection": self._should_include_search_context(), } -
indexer.py 14.7 KB
"""Code indexer: scan, hash, chunk, upload to ACE batch-upload API.""" import fnmatch import gzip import hashlib import json import logging import os import shutil import time from dataclasses import asdict, dataclass, field from pathlib import Path import httpx try: from .templates import ( BINARY_EXTENSIONS, ENCODING_CHAIN, EXCLUDE_PATTERNS, INDEX_DIR, INDEX_FILE, MAX_BATCH_SIZE, MAX_BLOB_SIZE, MAX_LINES_PER_BLOB, TEXT_EXTENSIONS, UPLOAD_BATCH_COUNT, USER_AGENT, ) from .utils import build_api_url, detect_and_read, get_session_id, sanitize_content except ImportError: from templates import ( BINARY_EXTENSIONS, ENCODING_CHAIN, EXCLUDE_PATTERNS, INDEX_DIR, INDEX_FILE, MAX_BATCH_SIZE, MAX_BLOB_SIZE, MAX_LINES_PER_BLOB, TEXT_EXTENSIONS, UPLOAD_BATCH_COUNT, USER_AGENT, ) from utils import build_api_url, detect_and_read, get_session_id, sanitize_content log = logging.getLogger(__name__) class IndexRebuildError(ValueError): """Forced re-upload failed. Subclasses ValueError so tenacity-wrapped callers do not retry the full re-upload.""" @dataclass class BlobEntry: path: str blob_name: str mtime: float size: int @dataclass class ProjectIndex: entries: dict[str, BlobEntry] = field(default_factory=dict) last_indexed: float = 0.0 class Indexer: def __init__(self, project_root: str, base_url: str, token: str): self.root = self._resolve_root(project_root) self.base_url = base_url.rstrip("/") self.token = token self.index_path = self.root / INDEX_DIR / INDEX_FILE self._index: ProjectIndex | None = None self._gitignore_patterns: list[str] = [] self._child_cache_dirs: set[Path] = set() self._load_ignore_patterns() @staticmethod def _resolve_root(project_root: str) -> Path: """Use the nearest ancestor with an existing index cache as the effective root.""" requested = Path(project_root).resolve() if (requested / INDEX_DIR / INDEX_FILE).is_file(): return requested home = Path.home().resolve() for parent in requested.parents: # Home and filesystem-root caches are too broad to inherit implicitly. if parent == home or parent.parent == parent: break if (parent / INDEX_DIR / INDEX_FILE).is_file(): log.info("Inheriting index cache from ancestor: %s", parent) return parent return requested def get_blob_names(self) -> list[str]: """Main entry: load/build index, upload pending, return blob_names.""" self._load_index() old_entries = dict(self._index.entries) changed = self._scan_and_update() if changed: if self._upload_pending(): if self._save_index(): self._absorb_child_caches() else: self._index.entries = old_entries elif self.index_path.is_file(): self._absorb_child_caches() return list(self._index.entries.keys()) def force_rebuild(self) -> list[str]: """Discard local index state, re-upload every blob (server lost our data).""" self._index = ProjectIndex() self._scan_and_update() if not self._upload_pending(): raise IndexRebuildError("Failed to re-upload blobs after index rebuild") if self._save_index(): self._absorb_child_caches() return list(self._index.entries.keys()) def _absorb_child_caches(self): """Delete child .ace-tool caches superseded by this root's index.""" own = self.index_path.parent for cache_dir in self._child_cache_dirs: if cache_dir == own or cache_dir.is_symlink(): continue if not self._covered_by_scan(cache_dir.parent): continue try: cache_dir.resolve().relative_to(self.root) if not (cache_dir / INDEX_FILE).is_file(): continue shutil.rmtree(cache_dir) log.info("Absorbed child cache: %s", cache_dir) except (OSError, ValueError) as e: log.warning("Failed to remove child cache %s: %s", cache_dir, e) def _covered_by_scan(self, owner: Path) -> bool: """True if the cache owner's subtree is included in this root's scan.""" rel_parts = owner.relative_to(self.root).parts if any(p in EXCLUDE_PATTERNS for p in rel_parts): return False if any(part.startswith(".") for part in rel_parts): return False return not self._is_gitignored(owner) # --- Index persistence --- def _load_index(self): if self._index is not None: return if self.index_path.exists(): try: with gzip.open(self.index_path, "rt", encoding="utf-8") as f: data = json.load(f) entries = {k: BlobEntry(**v) for k, v in data.get("entries", {}).items()} self._index = ProjectIndex(entries=entries, last_indexed=data.get("last_indexed", 0.0)) return except Exception as e: log.warning("Failed to load index, rebuilding: %s", e) self._index = ProjectIndex() def _save_index(self) -> bool: self._index.last_indexed = time.time() # Per-process tmp: concurrent same-root writers each publish a fully # written file, so a torn index can never be renamed into place. tmp = self.index_path.parent / f"{INDEX_FILE}.{os.getpid()}.tmp" data = { "entries": {k: asdict(v) for k, v in self._index.entries.items()}, "last_indexed": self._index.last_indexed, } try: self.index_path.parent.mkdir(parents=True, exist_ok=True) with gzip.open(tmp, "wt", encoding="utf-8") as f: json.dump(data, f) for attempt in range(3): try: tmp.replace(self.index_path) break except PermissionError: # Windows refuses to replace a target a concurrent reader # briefly holds open; genuine ACL errors still propagate. if attempt == 2: raise time.sleep(0.05 * (2**attempt)) except FileNotFoundError as e: # A concurrent ancestor index absorbed this cache dir mid-write. # Abandon persistence; the next run here inherits the ancestor root. log.warning("Index dir vanished during save (absorbed by ancestor?): %s", e) return False return True # --- Scanning --- def _scan_and_update(self) -> bool: self._child_cache_dirs = set() current_files: dict[str, Path] = {} for fp in self._walk_files(): rel = fp.relative_to(self.root).as_posix() current_files[rel] = fp old_entries = self._index.entries new_entries: dict[str, BlobEntry] = {} pending_blobs: list[dict] = [] changed = False # Group old entries by base path (strip chunk suffix) for cache hit old_by_path: dict[str, list[BlobEntry]] = {} for entry in old_entries.values(): base = entry.path.split("#chunk", 1)[0] old_by_path.setdefault(base, []).append(entry) for rel, fp in current_files.items(): try: stat = fp.stat() except OSError: continue mtime, size = stat.st_mtime, stat.st_size cached_list = old_by_path.get(rel) if cached_list and cached_list[0].mtime == mtime and cached_list[0].size == size: for cached in cached_list: new_entries[cached.blob_name] = cached continue blobs = self._process_file(fp, rel) if not blobs: continue changed = True for blob_name, path_label, content in blobs: new_entries[blob_name] = BlobEntry(path=path_label, blob_name=blob_name, mtime=mtime, size=size) if blob_name not in old_entries: pending_blobs.append({"path": path_label, "content": content, "blob_name": blob_name}) if set(new_entries.keys()) != set(old_entries.keys()): changed = True self._index.entries = new_entries self._pending = pending_blobs return changed def _walk_files(self): for fp in self.root.rglob("*"): try: if not fp.is_file(): continue except OSError: continue rel_parts = fp.relative_to(self.root).parts if fp.name == INDEX_FILE and fp.parent.name == INDEX_DIR: self._child_cache_dirs.add(fp.parent) if any(p in EXCLUDE_PATTERNS for p in rel_parts): continue if any(part.startswith(".") and part != "." for part in rel_parts): continue if fp.suffix.lower() in BINARY_EXTENSIONS: continue if fp.suffix.lower() not in TEXT_EXTENSIONS: continue try: if fp.stat().st_size > MAX_BLOB_SIZE: continue except OSError: continue if self._is_gitignored(fp): continue yield fp def _load_ignore_patterns(self): seen = set() for filename in (".gitignore", ".aceignore"): path = self.root / filename if path.exists(): try: for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = line.strip() if line and not line.startswith("#") and line not in seen: seen.add(line) self._gitignore_patterns.append(line) except Exception: pass def _is_gitignored(self, fp: Path) -> bool: rel = fp.relative_to(self.root).as_posix() name = fp.name for pat in self._gitignore_patterns: if fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(rel, pat): return True clean = pat.rstrip("/") if clean in rel.split("/") or rel.startswith(clean + "/"): return True return False # --- File processing --- def _process_file(self, fp: Path, rel: str) -> list[tuple[str, str, str]]: """Read, sanitize, chunk, hash. Returns [(blob_name, path_label, content), ...]""" content = detect_and_read(fp, ENCODING_CHAIN) if content is None: return [] content = sanitize_content(content) if not content.strip(): return [] lines = content.split("\n") if len(lines) <= MAX_LINES_PER_BLOB: blob_name = self._hash_blob(rel, content) return [(blob_name, rel, content)] # Chunk large files chunks = [] total_chunks = (len(lines) + MAX_LINES_PER_BLOB - 1) // MAX_LINES_PER_BLOB for i in range(total_chunks): start = i * MAX_LINES_PER_BLOB end = min(start + MAX_LINES_PER_BLOB, len(lines)) chunk_content = "\n".join(lines[start:end]) chunk_label = f"{rel}#chunk{i + 1}of{total_chunks}" blob_name = self._hash_blob(chunk_label, chunk_content) chunks.append((blob_name, chunk_label, chunk_content)) return chunks @staticmethod def _hash_blob(path: str, content: str) -> str: h = hashlib.sha256() h.update(path.encode("utf-8")) h.update(content.encode("utf-8")) return h.hexdigest() # --- Upload --- def _upload_pending(self) -> bool: if not self._pending or not self.base_url: return True batches = self._make_batches(self._pending) headers = { "Content-Type": "application/json", "User-Agent": USER_AGENT, "Authorization": f"Bearer {self.token}", "x-request-session-id": get_session_id(), } all_ok = True for batch in batches: payload = {"blobs": [{"path": b["path"], "content": b["content"]} for b in batch]} if not self._upload_batch_with_retry(headers, payload): all_ok = False self._pending = [] return all_ok def _make_batches(self, blobs: list[dict]) -> list[list[dict]]: batches = [] current_batch = [] current_size = 0 for b in blobs: item_size = len(b["content"].encode("utf-8")) if current_batch and ( len(current_batch) >= UPLOAD_BATCH_COUNT or current_size + item_size > MAX_BATCH_SIZE ): batches.append(current_batch) current_batch = [] current_size = 0 current_batch.append(b) current_size += item_size if current_batch: batches.append(current_batch) return batches def _upload_batch_with_retry(self, headers: dict, payload: dict, max_retries: int = 3) -> bool: url = build_api_url(self.base_url, "/batch-upload") for attempt in range(max_retries): try: with httpx.Client(timeout=httpx.Timeout(60.0, connect=15.0)) as client: resp = client.post(url, headers=headers, json=payload) if resp.status_code in (401, 403): log.error("Auth failed (%d) uploading blobs, aborting", resp.status_code) return False if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", "5")) log.warning("Rate limited, waiting %ds", retry_after) time.sleep(retry_after) continue if resp.status_code >= 500: wait = 2**attempt log.warning("Server error %d, retrying in %ds", resp.status_code, wait) time.sleep(wait) continue resp.raise_for_status() return True except httpx.TransportError as e: if attempt < max_retries - 1: time.sleep(2**attempt) log.warning("Transport error, retrying: %s", e) else: log.error("Upload failed after retries: %s", e) return False return False -
templates.py 5.1 KB
"""Prompt templates and constants for ACE-Tool CLI.""" USER_AGENT = "augment.cli/0.29.0" DEFAULT_MODEL = "claude-sonnet-4-5" # Default models for third-party APIs DEFAULT_CLAUDE_MODEL = "sonnet-4-6-20250929" DEFAULT_OPENAI_MODEL = "gpt-5.4" DEFAULT_CODEX_MODEL = "gpt-5.4" DEFAULT_GEMINI_MODEL = "gemini-3-flash-preview" # Environment variable names ENV_ENHANCER_ENDPOINT = "PROMPT_ENHANCER_ENDPOINT" ENV_ENHANCER_ENDPOINT_LEGACY = "ACE_ENHANCER_ENDPOINT" ENV_ENHANCER_INCLUDE_SEARCH_CONTEXT = "PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT" ENV_ENHANCER_REASONING_EFFORT = "PROMPT_ENHANCER_REASONING_EFFORT" # Search context injection SEARCH_CONTEXT_CHAR_LIMIT = 12000 NO_RELEVANT_CODE_CONTEXT = "No relevant code context found for your query." ENHANCE_PROMPT_TEMPLATE = """⚠️ NO TOOLS ALLOWED ⚠️ Here is an instruction that I'd like to give you, but it needs to be improved. Rewrite and enhance this instruction to make it clearer, more specific, less ambiguous, and correct any mistakes. Do not use any tools: reply immediately with your answer, even if you're not sure. Consider the context of our conversation history when enhancing the prompt. If there is code in triple backticks (```) consider whether it is a code sample and should remain unchanged.Reply with the following format: ### BEGIN RESPONSE ### Here is an enhanced version of the original instruction that is more specific and clear: <augment-enhanced-prompt>enhanced prompt goes here</augment-enhanced-prompt> ### END RESPONSE ### Here is my original instruction: {original_prompt}""" ITERATIVE_ENHANCE_TEMPLATE = """⚠️ NO TOOLS ALLOWED ⚠️ You are performing an ITERATIVE ENHANCEMENT on an already-enhanced prompt. The user has reviewed and possibly edited the previous enhancement. Your task is to further refine and optimize while PRESERVING the user's modifications and intent. **Context:** - Original prompt: {original_prompt} - Previous enhancement: {previous_enhanced} - Current version (user may have edited): {current_prompt} **Instructions:** 1. Identify what the user changed from the previous enhancement (their edits reflect their intent) 2. PRESERVE the user's modifications - do not revert their changes 3. Further optimize clarity, specificity, and correctness 4. If the user made no changes, provide alternative improvements or deeper refinement 5. Do not use any tools: reply immediately Reply with the following format: ### BEGIN RESPONSE ### <augment-enhanced-prompt>iteratively enhanced prompt goes here</augment-enhanced-prompt> ### END RESPONSE ###""" TEXT_EXTENSIONS = { ".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".java", ".go", ".rs", ".cpp", ".c", ".cc", ".h", ".hpp", ".hxx", ".cs", ".rb", ".php", ".swift", ".kt", ".kts", ".scala", ".clj", ".cljs", ".lua", ".dart", ".m", ".mm", ".pl", ".pm", ".r", ".R", ".jl", ".ex", ".exs", ".erl", ".hs", ".zig", ".v", ".nim", ".f90", ".f95", ".groovy", ".gradle", ".sol", ".move", ".md", ".mdx", ".txt", ".json", ".jsonc", ".json5", ".yaml", ".yml", ".toml", ".xml", ".ini", ".conf", ".cfg", ".properties", ".html", ".htm", ".css", ".scss", ".sass", ".less", ".styl", ".vue", ".svelte", ".astro", ".ejs", ".hbs", ".pug", ".jade", ".jinja", ".jinja2", ".erb", ".sql", ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd", ".graphql", ".gql", ".proto", ".prisma", } EXCLUDE_PATTERNS = { ".venv", "venv", ".env", "env", "node_modules", "vendor", ".pnpm", ".yarn", "bower_components", ".git", ".svn", ".hg", "__pycache__", ".pytest_cache", ".mypy_cache", ".tox", ".ruff_cache", "dist", "build", "target", "out", "bin", "obj", ".next", ".nuxt", ".output", ".vercel", ".netlify", ".turbo", ".parcel-cache", ".cache", ".temp", ".tmp", "coverage", ".nyc_output", "htmlcov", ".idea", ".vscode", ".vs", ".ace-tool", } # Indexing MAX_BLOB_SIZE = 128 * 1024 # 128KB MAX_BATCH_SIZE = 1 * 1024 * 1024 # 1MB MAX_LINES_PER_BLOB = 800 UPLOAD_BATCH_COUNT = 30 RETRIEVAL_TIMEOUT = 60.0 INDEX_DIR = ".ace-tool" INDEX_FILE = "index.json.gz" ENCODING_CHAIN = ["utf-8", "gbk", "gb18030", "cp1252"] BINARY_EXTENSIONS = { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".mp3", ".mp4", ".avi", ".mov", ".wav", ".ogg", ".flv", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz", ".exe", ".dll", ".so", ".dylib", ".o", ".a", ".lib", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".pyc", ".pyo", ".class", ".jar", ".db", ".sqlite", ".sqlite3", ".bin", ".dat", ".pak", ".bundle", } -
utils.py 6.6 KB
"""Utility functions for ACE-Tool CLI.""" import os import re import uuid from pathlib import Path _SESSION_ID: str | None = None _VERSION_SUFFIX_RE = re.compile(r"/v\d[A-Za-z0-9_-]*$") _VERSION_PREFIX_RE = re.compile(r"^/v\d[A-Za-z0-9_-]*(?=/|$)") def _has_version_suffix(url: str) -> tuple[bool, int]: match = _VERSION_SUFFIX_RE.search(url.rstrip("/")) return (True, match.start()) if match else (False, -1) def _strip_version_prefix(path: str) -> str: return _VERSION_PREFIX_RE.sub("", path, count=1) def build_api_url(base_url: str, path: str) -> str: base_url = base_url.rstrip("/") if not path.startswith("/"): path = "/" + path has_ver, ver_idx = _has_version_suffix(base_url) if has_ver: stripped = _strip_version_prefix(path) return base_url + stripped return base_url + path def load_env(): """Load environment variables from the skill root .env file.""" env_path = Path(__file__).resolve().parent.parent / ".env" if env_path.exists(): with open(env_path) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: key, value = line.split("=", 1) value = value.strip() # Strip inline comments (unquoted # preceded by whitespace) if value and value[0] not in ('"', "'"): value = re.split(r"\s+#", value, maxsplit=1)[0].strip() else: value = value.strip("\"'") os.environ.setdefault(key.strip(), value) def get_session_id() -> str: """Get or create persistent session ID.""" global _SESSION_ID if _SESSION_ID is None: _SESSION_ID = str(uuid.uuid4()) return _SESSION_ID def is_chinese_text(text: str) -> bool: """Detect if text is primarily Chinese.""" chinese_chars = re.findall(r"[\u4e00-\u9fa5]", text) if not chinese_chars: return False if len(chinese_chars) >= 3: return True non_ws = len([c for c in text if not c.isspace()]) return non_ws > 0 and len(chinese_chars) / non_ws >= 0.1 def parse_chat_history(conversation_history: str) -> list[dict]: """Parse conversation history into ChatMessage format.""" messages = [] current_role = None current_lines = [] user_prefixes = ["User:", "用户:"] assistant_prefixes = ["AI:", "Assistant:", "助手:"] for line in conversation_history.split("\n"): trimmed = line.strip() if not trimmed: if current_role: current_lines.append("") continue role_found = None content = None for prefix in user_prefixes: if trimmed.startswith(prefix): role_found = "user" content = trimmed[len(prefix) :].strip() break if not role_found: for prefix in assistant_prefixes: if trimmed.startswith(prefix): role_found = "assistant" content = trimmed[len(prefix) :].strip() break if role_found: if current_role: messages.append({"role": current_role, "content": "\n".join(current_lines)}) current_role = role_found current_lines = [content] elif current_role: current_lines.append(line) if current_role: messages.append({"role": current_role, "content": "\n".join(current_lines)}) return messages def detect_and_read(file_path: Path, encoding_chain: list[str]) -> str | None: """Try multiple encodings to read a file. Returns None for binary/unreadable.""" try: raw = file_path.read_bytes() except OSError: return None if b"\x00" in raw[:8192]: return None for enc in encoding_chain: try: return raw.decode(enc) except (UnicodeDecodeError, LookupError): continue return None def sanitize_content(content: str) -> str: """Normalize line endings and remove null bytes.""" return content.replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "") def _validate_session_data(data: dict) -> tuple[str | None, str | None]: """Validate and extract session data fields. Returns (tenant_url, access_token) or (None, None) if invalid. """ if not isinstance(data, dict): return None, None access_token = data.get("accessToken", "") tenant_url = data.get("tenantURL", "") if isinstance(access_token, str) and access_token.strip() and isinstance(tenant_url, str) and tenant_url.strip(): return tenant_url.rstrip("/"), access_token return None, None def load_session_auth() -> tuple[str | None, str | None, str]: """Load authentication from session.json, AUGMENT_SESSION_AUTH, or legacy env vars. Returns: (base_url, token, source) where source is one of: - "session.json" - "AUGMENT_SESSION_AUTH" - "ACE_API_TOKEN" - "none" """ import json import logging log = logging.getLogger(__name__) # Try session.json first session_path = Path.home() / ".augment" / "session.json" if session_path.exists(): try: content = session_path.read_text(encoding="utf-8-sig") data = json.loads(content) tenant_url, access_token = _validate_session_data(data) if tenant_url and access_token: log.debug("Auth source: session.json") return tenant_url, access_token, "session.json" except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e: log.debug("Failed to load session.json: %s", e) # Try AUGMENT_SESSION_AUTH env var env_session = os.getenv("AUGMENT_SESSION_AUTH", "") if env_session: try: data = json.loads(env_session) tenant_url, access_token = _validate_session_data(data) if tenant_url and access_token: log.debug("Auth source: AUGMENT_SESSION_AUTH") return tenant_url, access_token, "AUGMENT_SESSION_AUTH" except (json.JSONDecodeError, TypeError) as e: log.debug("Failed to parse AUGMENT_SESSION_AUTH: %s", e) # Fallback to legacy env vars legacy_url = os.getenv("ACE_API_URL", "").rstrip("/") legacy_token = os.getenv("ACE_API_TOKEN", "") if legacy_url or legacy_token: log.debug("Auth source: ACE_API_TOKEN (legacy)") return legacy_url or None, legacy_token or None, "ACE_API_TOKEN" log.debug("Auth source: none") return None, None, "none" -
web_ui.py 21.3 KB
"""Web UI for interactive prompt enhancement.""" import contextlib import json import os import queue import socket import sys import threading import time import uuid import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer from typing import TYPE_CHECKING from urllib.parse import parse_qs, urlparse if TYPE_CHECKING: try: from .client import AceToolClient except ImportError: from client import AceToolClient _SESSIONS: dict = {} _RESULT_QUEUE: queue.Queue | None = None HTML_TEMPLATE = """<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Prompt Enhancer - ACE Tool</title> <style> *{margin:0;padding:0;box-sizing:border-box} body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;min-height:100vh;padding:20px;display:flex;align-items:center;justify-content:center} .container{background:#fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.1);border:1px solid #e0e0e0;max-width:1000px;width:100%;overflow:hidden} .header{background:#fff;color:#333;padding:30px;text-align:center;border-bottom:1px solid #e0e0e0} .header h1{font-size:24px;font-weight:600;margin-bottom:8px;display:flex;align-items:center;justify-content:center;gap:10px} .header p{font-size:14px;color:#666} .countdown{margin-top:12px;padding:8px 16px;background:#f0f0f0;border-radius:6px;display:inline-block;font-size:13px;font-weight:500;color:#555} .countdown.warning{background:#fff3cd;color:#856404} .countdown.danger{background:#f8d7da;color:#721c24;animation:pulse 1s ease-in-out infinite} @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.7}} .content{padding:30px} .section{margin-bottom:25px} .section-title{font-size:14px;font-weight:600;color:#333;margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px} .editor-wrapper{position:relative} textarea{width:100%;min-height:350px;padding:16px;border:2px solid #e0e0e0;border-radius:8px;font-family:'SF Mono',Monaco,Menlo,Consolas,monospace;font-size:14px;line-height:1.6;resize:vertical;background:#fafafa} textarea:focus{outline:none;border-color:#333;background:#fff} .char-count{position:absolute;bottom:12px;right:12px;background:rgba(255,255,255,0.9);padding:4px 10px;border-radius:12px;font-size:12px;color:#666;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,0.1)} .info-box{background:#f9f9f9;border-left:4px solid #333;padding:15px;border-radius:4px;margin-bottom:20px} .info-box p{font-size:14px;color:#555;line-height:1.6} .buttons{display:flex;gap:12px;justify-content:flex-end;margin-top:25px;flex-wrap:wrap} button{padding:12px 28px;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:all 0.3s;display:flex;align-items:center;gap:8px} button:focus-visible{outline:2px solid #333;outline-offset:2px} .send-btn{background:#333;color:#fff} .send-btn:hover:not(:disabled){background:#000} .send-btn:disabled{background:#ccc;cursor:not-allowed} .cancel-btn{background:#fff;color:#666;border:2px solid #e0e0e0} .cancel-btn:hover{background:#f5f5f5;border-color:#ccc} .re-enhance-btn{background:#fff;color:#333;border:2px solid #333} .re-enhance-btn:hover:not(:disabled){background:#f5f5f5} .re-enhance-btn:disabled{background:#f5f5f5;color:#ccc;border-color:#e0e0e0;cursor:not-allowed} .status{margin-top:20px;padding:15px;border-radius:8px;display:none;animation:slideIn 0.3s ease} @keyframes slideIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}} .status.success{background:#d4edda;color:#155724;border-left:4px solid #28a745;display:block} .status.error{background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;display:block} .loading{display:none;text-align:center;padding:40px} .loading.active{display:block} .spinner{border:3px solid #f3f3f3;border-top:3px solid #333;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto 15px} @keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}} .keyboard-hint{font-size:12px;color:#999;text-align:center;margin-top:15px} .keyboard-hint kbd{background:#f5f5f5;border:1px solid #ddd;border-radius:4px;padding:2px 6px;font-family:monospace;font-size:11px} </style> </head> <body> <div class="container"> <div class="header"> <h1><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>Prompt Enhancer</h1> <p>Review and refine your enhanced prompt</p> <div class="countdown" id="countdown" aria-live="polite">Loading...</div> </div> <div class="content"> <div class="loading" id="loading" role="status" aria-live="polite"><div class="spinner" aria-hidden="true"></div><p>Loading your enhanced prompt...</p></div> <div id="mainContent" style="display:none"> <div class="info-box"><p><strong>Tip:</strong> AI has enhanced your prompt. You can edit it below, then click "Send Enhanced" to continue. Use "Regenerate" to get a completely new enhancement, or "Refine" to improve the current version while keeping your edits.</p></div> <div class="section"> <div class="section-title">Enhanced Prompt</div> <div class="editor-wrapper"> <textarea id="promptText" aria-label="Enhanced prompt" placeholder="Your enhanced prompt will appear here..." spellcheck="false"></textarea> <div class="char-count" id="charCount">0 chars</div> </div> </div> <div class="buttons"> <button class="cancel-btn" onclick="endConversation()">Cancel</button> <button class="re-enhance-btn" id="regenerateBtn" onclick="regenerate()" title="Discard current and generate a new enhancement from scratch">Regenerate</button> <button class="re-enhance-btn" id="refineBtn" onclick="refine()" title="Further improve current version while preserving your edits">Refine</button> <button class="cancel-btn" onclick="useOriginal()">Use Original</button> <button class="send-btn" id="sendBtn" onclick="sendPrompt()">Send Enhanced</button> </div> <div class="keyboard-hint">Shortcuts: <kbd>Ctrl</kbd>+<kbd>Enter</kbd> Send | <kbd>Esc</kbd> Cancel</div> <div id="status" class="status" role="alert" aria-live="assertive"></div> </div> </div> </div> <script> const urlParams=new URLSearchParams(window.location.search); const sessionId=urlParams.get('session'); const promptText=document.getElementById('promptText'); const charCount=document.getElementById('charCount'); const loading=document.getElementById('loading'); const mainContent=document.getElementById('mainContent'); const countdownEl=document.getElementById('countdown'); let countdownInterval=null,sessionCreatedAt=null,sessionTimeoutMs=null; function updateCharCount(){charCount.textContent=promptText.value.length+' chars'} promptText.addEventListener('input',updateCharCount); function formatTime(ms){const s=Math.floor(ms/1000),m=Math.floor(s/60);return m+':'+(s%60).toString().padStart(2,'0')} function updateCountdown(){ if(!sessionCreatedAt||!sessionTimeoutMs)return; const remaining=sessionTimeoutMs-(Date.now()-sessionCreatedAt); if(remaining<=0){countdownEl.textContent='Timed out';countdownEl.className='countdown danger';clearInterval(countdownInterval);return} countdownEl.className='countdown'+(remaining<60000?' danger':remaining<180000?' warning':''); countdownEl.textContent='Remaining: '+formatTime(remaining); } function startCountdown(createdAt,timeoutMs){sessionCreatedAt=createdAt;sessionTimeoutMs=timeoutMs;updateCountdown();countdownInterval=setInterval(updateCountdown,1000)} document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();sendPrompt()}else if(e.key==='Escape'){e.preventDefault();endConversation()}}); if(!sessionId){loading.style.display='none';mainContent.style.display='block';showStatus('Error: No session ID','error')} else{loading.classList.add('active'); fetch('/api/session?session='+encodeURIComponent(sessionId)).then(r=>r.json()).then(data=>{ if(data.error)throw new Error(data.error); promptText.value=data.enhancedPrompt;updateCharCount(); loading.classList.remove('active');mainContent.style.display='block';promptText.focus(); if(data.createdAt&&data.timeoutMs)startCountdown(data.createdAt,data.timeoutMs); }).catch(err=>{loading.classList.remove('active');mainContent.style.display='block';showStatus('Load failed: '+err.message,'error')})} function regenerate(){ const btn=document.getElementById('regenerateBtn'),refineBtn=document.getElementById('refineBtn'),sendBtn=document.getElementById('sendBtn'); btn.disabled=refineBtn.disabled=sendBtn.disabled=true;btn.innerHTML='<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0"></div> Regenerating...'; fetch('/api/regenerate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId})}) .then(r=>r.json()).then(data=>{if(data.error)throw new Error(data.error);promptText.value=data.enhancedPrompt;updateCharCount();showStatus('Regenerated!','success');btn.disabled=refineBtn.disabled=sendBtn.disabled=false;btn.innerHTML='Regenerate'}) .catch(err=>{showStatus('Failed: '+err.message,'error');btn.disabled=refineBtn.disabled=sendBtn.disabled=false;btn.innerHTML='Regenerate'})} function refine(){ const content=promptText.value.trim();if(!content){showStatus('Please enter content','error');return} const btn=document.getElementById('refineBtn'),regenerateBtn=document.getElementById('regenerateBtn'),sendBtn=document.getElementById('sendBtn'); btn.disabled=regenerateBtn.disabled=sendBtn.disabled=true;btn.innerHTML='<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0"></div> Refining...'; fetch('/api/refine',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId,currentPrompt:content})}) .then(r=>r.json()).then(data=>{if(data.error)throw new Error(data.error);promptText.value=data.enhancedPrompt;updateCharCount();showStatus('Refined!','success');btn.disabled=regenerateBtn.disabled=sendBtn.disabled=false;btn.innerHTML='Refine'}) .catch(err=>{showStatus('Failed: '+err.message,'error');btn.disabled=regenerateBtn.disabled=sendBtn.disabled=false;btn.innerHTML='Refine'})} function sendPrompt(){ const content=promptText.value.trim();if(!content){showStatus('Please enter content','error');return} const btn=document.getElementById('sendBtn'),regenerateBtn=document.getElementById('regenerateBtn'),refineBtn=document.getElementById('refineBtn'); btn.disabled=regenerateBtn.disabled=refineBtn.disabled=true;btn.innerHTML='<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0"></div> Sending...'; fetch('/api/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId,content,action:'send'})}) .then(r=>r.json()).then(data=>{if(data.error)throw new Error(data.error);showStatus('Sent! Closing...','success');setTimeout(()=>window.close(),1500)}) .catch(err=>{showStatus('Failed: '+err.message,'error');btn.disabled=regenerateBtn.disabled=refineBtn.disabled=false;btn.innerHTML='Send Enhanced'})} function useOriginal(){if(confirm('Use original prompt?')){ fetch('/api/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId,content:'',action:'use_original'})}) .then(r=>r.json()).then(data=>{if(data.error)throw new Error(data.error);showStatus('Using original...','success');setTimeout(()=>window.close(),1000)}) .catch(err=>showStatus('Failed: '+err.message,'error'))}} function endConversation(){if(confirm('Cancel enhancement?')){ fetch('/api/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId,content:'',action:'cancel'})}) .then(r=>r.json()).then(data=>{if(data.error)throw new Error(data.error);showStatus('Cancelled','success');setTimeout(()=>window.close(),1000)}) .catch(err=>showStatus('Failed: '+err.message,'error'))}} function showStatus(msg,type){const s=document.getElementById('status');s.textContent=msg;s.className='status '+type} </script> </body> </html>""" class EnhanceRequestHandler(BaseHTTPRequestHandler): client: "AceToolClient" = None original_prompt: str = "" conversation_history: str = "" def log_message(self, fmt, *args): pass def handle(self): with contextlib.suppress(ConnectionAbortedError, ConnectionResetError, BrokenPipeError): super().handle() def do_GET(self): parsed = urlparse(self.path) if parsed.path == "/enhance": self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() self.wfile.write(HTML_TEMPLATE.encode("utf-8")) elif parsed.path == "/api/session": self._handle_get_session(parsed.query) else: self.send_error(404) def do_POST(self): parsed = urlparse(self.path) length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length).decode("utf-8") if parsed.path == "/api/submit": self._handle_submit(body) elif parsed.path == "/api/regenerate": self._handle_regenerate(body) elif parsed.path == "/api/refine": self._handle_refine(body) else: self.send_error(404) def _send_json(self, data: dict, status: int = 200): self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(json.dumps(data).encode("utf-8")) def _handle_get_session(self, query: str): params = parse_qs(query) session_id = params.get("session", [None])[0] if not session_id or session_id not in _SESSIONS: self._send_json({"error": "Session not found"}, 404) return session = _SESSIONS[session_id] self._send_json( { "enhancedPrompt": session["enhanced"], "status": session["status"], "createdAt": session["created_at"], "timeoutMs": session["timeout_ms"], } ) def _handle_submit(self, body: str): try: data = json.loads(body) except json.JSONDecodeError: self._send_json({"error": "Invalid JSON"}, 400) return session_id = data.get("sessionId") action = data.get("action", "send") content = data.get("content", "") if not session_id or session_id not in _SESSIONS: self._send_json({"error": "Session not found"}, 404) return session = _SESSIONS[session_id] if session["status"] != "pending": self._send_json({"error": "Session already completed"}, 400) return session["status"] = "completed" if action == "cancel": result = None elif action == "use_original": result = session["original"] else: result = content if content else session["enhanced"] if _RESULT_QUEUE: _RESULT_QUEUE.put(result) self._send_json({"success": True}) def _handle_regenerate(self, body: str): """Regenerate: discard current and generate new enhancement from original prompt.""" try: data = json.loads(body) except json.JSONDecodeError: self._send_json({"error": "Invalid JSON"}, 400) return session_id = data.get("sessionId") if not session_id or session_id not in _SESSIONS: self._send_json({"error": "Session not found"}, 404) return session = _SESSIONS[session_id] try: result = self.client.enhance_prompt( session["original"], self.conversation_history, session.get("project_root"), ) new_enhanced = result.get("enhanced_prompt", session["original"]) session["previous_enhanced"] = session["enhanced"] session["enhanced"] = new_enhanced session["regenerate_count"] = session.get("regenerate_count", 0) + 1 self._send_json( { "enhancedPrompt": new_enhanced, "regenerateCount": session["regenerate_count"], } ) except Exception as e: self._send_json({"error": str(e)}, 500) def _handle_refine(self, body: str): """Refine: iteratively improve current version while preserving user edits.""" try: data = json.loads(body) except json.JSONDecodeError: self._send_json({"error": "Invalid JSON"}, 400) return session_id = data.get("sessionId") current_prompt = data.get("currentPrompt", "") if not session_id or session_id not in _SESSIONS: self._send_json({"error": "Session not found"}, 404) return session = _SESSIONS[session_id] try: previous_enhanced = session.get("previous_enhanced") or session["enhanced"] result = self.client.iterative_enhance( original_prompt=session["original"], previous_enhanced=previous_enhanced, current_prompt=current_prompt, conversation_history=self.conversation_history, project_root=session.get("project_root"), ) new_enhanced = result.get("enhanced_prompt", current_prompt) session["previous_enhanced"] = session["enhanced"] session["enhanced"] = new_enhanced session["refine_count"] = session.get("refine_count", 0) + 1 self._send_json( { "enhancedPrompt": new_enhanced, "refineCount": session["refine_count"], } ) except Exception as e: self._send_json({"error": str(e)}, 500) def run_interactive_enhance( client: "AceToolClient", prompt: str, history: str, port: int = 8765, auto_open_browser: bool = True, project_root: str | None = None, ) -> str | None: """Run interactive web-based prompt enhancement.""" global _RESULT_QUEUE print("Enhancing prompt...", file=sys.stderr) result = client.enhance_prompt(prompt, history, project_root) if "error" in result: print(f"Enhancement error: {result['error']}", file=sys.stderr) return None enhanced = result.get("enhanced_prompt", prompt) session_id = str(uuid.uuid4()) created_at = int(time.time() * 1000) timeout_ms = 5 * 60 * 1000 _SESSIONS[session_id] = { "original": prompt, "enhanced": enhanced, "previous_enhanced": None, "regenerate_count": 0, "refine_count": 0, "status": "pending", "created_at": created_at, "timeout_ms": timeout_ms, "project_root": project_root, } _RESULT_QUEUE = queue.Queue() EnhanceRequestHandler.client = client EnhanceRequestHandler.original_prompt = prompt EnhanceRequestHandler.conversation_history = history for p in range(port, port + 100): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", p)) port = p break except OSError: continue server = HTTPServer(("127.0.0.1", port), EnhanceRequestHandler) server.timeout = 0.5 shutdown_flag = threading.Event() server_ready = threading.Event() def serve(): server_ready.set() while not shutdown_flag.is_set(): server.handle_request() thread = threading.Thread(target=serve, daemon=True) thread.start() server_ready.wait(timeout=2) url = f"http://127.0.0.1:{port}/enhance?session={session_id}" print(f"Opening browser: {url}", file=sys.stderr) def open_browser(): import subprocess if sys.platform == "win32": # Try multiple methods on Windows methods = [ lambda: os.startfile(url), lambda: subprocess.run(["cmd", "/c", "start", "", url], check=True, shell=False), lambda: subprocess.run(["powershell", "-Command", f"Start-Process '{url}'"], check=True), lambda: webbrowser.open(url), ] elif sys.platform == "darwin": methods = [ lambda: subprocess.run(["open", url], check=True), lambda: webbrowser.open(url), ] else: methods = [ lambda: subprocess.run(["xdg-open", url], check=True), lambda: webbrowser.open(url), ] for method in methods: try: method() return True except Exception as e: print(f"Browser open method failed: {e}", file=sys.stderr) continue return False if auto_open_browser: if not open_browser(): print(f"Could not open browser. Please open manually: {url}", file=sys.stderr) else: print(f"Browser auto-open disabled. Please open: {url}", file=sys.stderr) print("Waiting for user action...", file=sys.stderr) try: result = _RESULT_QUEUE.get(timeout=timeout_ms / 1000) except queue.Empty: result = None print("Timeout waiting for user action", file=sys.stderr) shutdown_flag.set() thread.join(timeout=1) server.server_close() del _SESSIONS[session_id] _RESULT_QUEUE = None return result -
__init__.py 630 B
"""ACE-Tool CLI package.""" from .client import AceToolClient from .indexer import Indexer from .utils import detect_and_read, get_session_id, is_chinese_text, load_env, parse_chat_history, sanitize_content from .web_ui import run_interactive_enhance # Auto-load environment variables on import. Client/indexer/web_ui only read env # in methods, not at import time, so this can sit after the imports. load_env() __all__ = [ "AceToolClient", "Indexer", "load_env", "get_session_id", "is_chinese_text", "parse_chat_history", "detect_and_read", "sanitize_content", "run_interactive_enhance", ] -
__main__.py 125 B
"""Entry point for running as module: python -m scripts""" from .ace_cli import main if __name__ == "__main__": main()
-
-
tests
-
conftest.py 113 B
import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -
test_auth_upgrade.py 7.8 KB
"""Tests for authentication upgrade: load_session_auth() function.""" import json import os from unittest.mock import patch try: from scripts import utils from scripts.utils import load_env, load_session_auth except ImportError: import utils from utils import load_env, load_session_auth class TestLoadSessionAuth: """Test load_session_auth() authentication fallback chain.""" def test_session_json_valid(self, tmp_path): """Test loading from valid session.json file.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "token_from_file", "tenantURL": "https://file.example.com/", "scopes": ["email"]} session_file.write_text(json.dumps(session_data), encoding="utf-8") with patch("pathlib.Path.home", return_value=tmp_path): base_url, token, source = load_session_auth() assert base_url == "https://file.example.com" assert token == "token_from_file" assert source == "session.json" def test_augment_session_auth_env(self): """Test loading from AUGMENT_SESSION_AUTH environment variable.""" env_data = {"accessToken": "token_from_env", "tenantURL": "https://env.example.com/"} with ( patch.dict(os.environ, {"AUGMENT_SESSION_AUTH": json.dumps(env_data)}, clear=False), patch("pathlib.Path.exists", return_value=False), ): base_url, token, source = load_session_auth() assert base_url == "https://env.example.com" assert token == "token_from_env" assert source == "AUGMENT_SESSION_AUTH" def test_legacy_env_fallback(self, tmp_path): """Test fallback to legacy ACE_API_URL and ACE_API_TOKEN.""" env_vars = { "ACE_API_URL": "https://legacy.example.com/", "ACE_API_TOKEN": "legacy_token", "AUGMENT_SESSION_AUTH": "", } with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False): base_url, token, source = load_session_auth() assert base_url == "https://legacy.example.com" assert token == "legacy_token" assert source == "ACE_API_TOKEN" def test_all_missing_returns_none(self, tmp_path): """Test returns (None, None, 'none') when all sources missing.""" env_vars = {"ACE_API_URL": "", "ACE_API_TOKEN": "", "AUGMENT_SESSION_AUTH": ""} with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False): base_url, token, source = load_session_auth() assert base_url is None assert token is None assert source == "none" def test_session_json_file_not_found(self, tmp_path): """Test graceful handling when session.json doesn't exist.""" env_data = {"accessToken": "env_token", "tenantURL": "https://env.example.com/"} with ( patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, {"AUGMENT_SESSION_AUTH": json.dumps(env_data)}, clear=False), ): base_url, token, source = load_session_auth() assert base_url == "https://env.example.com" assert token == "env_token" assert source == "AUGMENT_SESSION_AUTH" def test_session_json_invalid_json(self, tmp_path): """Test fallback when session.json contains invalid JSON.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_file.write_text("not valid json{", encoding="utf-8") env_data = {"accessToken": "fallback_token", "tenantURL": "https://fallback.example.com/"} with ( patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, {"AUGMENT_SESSION_AUTH": json.dumps(env_data)}, clear=False), ): base_url, token, source = load_session_auth() assert base_url == "https://fallback.example.com" assert token == "fallback_token" assert source == "AUGMENT_SESSION_AUTH" def test_session_json_missing_fields(self, tmp_path): """Test fallback when session.json missing required fields.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"scopes": ["email"]} # Missing accessToken and tenantURL session_file.write_text(json.dumps(session_data), encoding="utf-8") env_vars = {"ACE_API_URL": "https://legacy.example.com/", "ACE_API_TOKEN": "legacy_token"} with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=True): base_url, token, source = load_session_auth() assert base_url == "https://legacy.example.com" assert token == "legacy_token" assert source == "ACE_API_TOKEN" def test_session_json_empty_strings(self, tmp_path): """Test rejection of empty string values.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "", "tenantURL": " "} session_file.write_text(json.dumps(session_data), encoding="utf-8") env_vars = {"ACE_API_URL": "https://legacy.example.com/", "ACE_API_TOKEN": "legacy_token"} with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=True): base_url, token, source = load_session_auth() assert base_url == "https://legacy.example.com" assert token == "legacy_token" assert source == "ACE_API_TOKEN" def test_session_json_with_bom(self, tmp_path): """Test UTF-8 BOM handling.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "bom_token", "tenantURL": "https://bom.example.com/"} session_file.write_bytes(b"\xef\xbb\xbf" + json.dumps(session_data).encode("utf-8")) with patch("pathlib.Path.home", return_value=tmp_path): base_url, token, source = load_session_auth() assert base_url == "https://bom.example.com" assert token == "bom_token" assert source == "session.json" def test_url_trailing_slash_stripped(self, tmp_path): """Test that trailing slashes are removed from URLs.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "token", "tenantURL": "https://example.com///"} session_file.write_text(json.dumps(session_data), encoding="utf-8") with patch("pathlib.Path.home", return_value=tmp_path): base_url, token, source = load_session_auth() assert base_url == "https://example.com" assert token == "token" assert source == "session.json" class TestLoadEnv: """Test .env loading behavior.""" def test_loads_only_skill_root_env(self, tmp_path, monkeypatch): """Test loading only the skill root .env file.""" cwd_env = tmp_path / ".env" cwd_env.write_text("UNRELATED=value\nPROMPT_ENHANCER_ENDPOINT=wrong\n", encoding="utf-8") skill_root = tmp_path / "skill" scripts_dir = skill_root / "scripts" scripts_dir.mkdir(parents=True) skill_env = skill_root / ".env" skill_env.write_text("PROMPT_ENHANCER_ENDPOINT=openai\n", encoding="utf-8") monkeypatch.chdir(tmp_path) monkeypatch.setattr(utils, "__file__", str(scripts_dir / "utils.py")) monkeypatch.delenv("UNRELATED", raising=False) monkeypatch.delenv("PROMPT_ENHANCER_ENDPOINT", raising=False) load_env() assert "UNRELATED" not in os.environ assert os.environ["PROMPT_ENHANCER_ENDPOINT"] == "openai" -
test_cli.py 3 KB
"""CLI command behavior: index auth resolution, enhance error exit codes.""" import json import os from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest try: from scripts import ace_cli except ImportError: import ace_cli def _index_args(project_root): return SimpleNamespace(api_url=None, token=None, endpoint=None, project_root=str(project_root)) class TestIndexAuth: """Test cmd_index resolves auth via the full session chain, not legacy vars only.""" def test_index_accepts_augment_session_auth(self, tmp_path): """Regression: index must work with AUGMENT_SESSION_AUTH (new session format).""" env_data = {"accessToken": "session_token", "tenantURL": "https://session.example.com/"} env_vars = {"AUGMENT_SESSION_AUTH": json.dumps(env_data), "ACE_API_URL": "", "ACE_API_TOKEN": ""} captured = {} class FakeIndexer: def __init__(self, project_root, base_url, token): captured["base_url"] = base_url captured["token"] = token self.root = project_root self._index = SimpleNamespace(last_indexed=0.0) def get_blob_names(self): return ["blob1", "blob2"] with ( patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False), patch.object(ace_cli, "Indexer", FakeIndexer), ): ace_cli.cmd_index(_index_args(tmp_path)) assert captured["base_url"] == "https://session.example.com" assert captured["token"] == "session_token" def test_index_errors_when_no_auth(self, tmp_path, capsys): """Index exits with a clear error when no auth source is configured.""" env_vars = {"AUGMENT_SESSION_AUTH": "", "ACE_API_URL": "", "ACE_API_TOKEN": ""} with ( patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False), pytest.raises(SystemExit) as exc, ): ace_cli.cmd_index(_index_args(tmp_path)) assert exc.value.code == 1 err = json.loads(capsys.readouterr().err) assert "authentication" in err["error"].lower() class TestCmdEnhancePromptError: """cmd_enhance_prompt exits 1 when enhancement returns an error dict.""" def test_error_exits_with_code_1(self): args = MagicMock() args.no_interactive = True args.prompt = "test" args.history = "" args.history_file = None args.project_root = None args.api_url = None args.token = None args.endpoint = "new" with ( patch.dict(os.environ, {"ACE_API_URL": "", "ACE_API_TOKEN": ""}, clear=False), patch.object(ace_cli, "AceToolClient") as mock_client, ): mock_client.return_value.enhance_prompt.return_value = {"error": "test error"} with pytest.raises(SystemExit) as exc_info: ace_cli.cmd_enhance_prompt(args) assert exc_info.value.code == 1 -
test_client_auth_integration.py 4.9 KB
"""Tests for AceToolClient authentication integration.""" import json import os from unittest.mock import patch try: from scripts.client import AceToolClient except ImportError: from client import AceToolClient class TestClientAuthIntegration: """Test AceToolClient uses load_session_auth() correctly.""" def test_constructor_params_override_all(self, tmp_path): """Test constructor parameters take highest priority.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "file_token", "tenantURL": "https://file.example.com/"} session_file.write_text(json.dumps(session_data), encoding="utf-8") env_vars = {"ACE_API_URL": "https://env.example.com/", "ACE_API_TOKEN": "env_token"} with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False): client = AceToolClient(base_url="https://constructor.example.com", token="constructor_token") assert client.base_url == "https://constructor.example.com" assert client.token == "constructor_token" assert client.auth_source == "constructor" def test_session_json_used_when_no_constructor_params(self, tmp_path): """Test session.json used when constructor params not provided.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "session_token", "tenantURL": "https://session.example.com/"} session_file.write_text(json.dumps(session_data), encoding="utf-8") with patch("pathlib.Path.home", return_value=tmp_path): client = AceToolClient() assert client.base_url == "https://session.example.com" assert client.token == "session_token" assert client.auth_source == "session.json" def test_augment_session_auth_fallback(self, tmp_path): """Test AUGMENT_SESSION_AUTH used when session.json missing.""" env_data = {"accessToken": "env_token", "tenantURL": "https://env.example.com/"} env_vars = {"AUGMENT_SESSION_AUTH": json.dumps(env_data)} with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False): client = AceToolClient() assert client.base_url == "https://env.example.com" assert client.token == "env_token" assert client.auth_source == "AUGMENT_SESSION_AUTH" def test_legacy_env_vars_lowest_priority(self, tmp_path): """Test legacy ACE_API_* vars used as last resort.""" env_vars = { "ACE_API_URL": "https://legacy.example.com/", "ACE_API_TOKEN": "legacy_token", "AUGMENT_SESSION_AUTH": "", } with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False): client = AceToolClient() assert client.base_url == "https://legacy.example.com" assert client.token == "legacy_token" assert client.auth_source == "ACE_API_TOKEN" def test_partial_constructor_params(self, tmp_path): """Test partial constructor params override only those fields.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "session_token", "tenantURL": "https://session.example.com/"} session_file.write_text(json.dumps(session_data), encoding="utf-8") with patch("pathlib.Path.home", return_value=tmp_path): client = AceToolClient(token="override_token") # Only token overridden, base_url from session.json assert client.base_url == "https://session.example.com" assert client.token == "override_token" assert client.auth_source == "constructor" def test_no_auth_configured(self, tmp_path): """Test client handles missing authentication gracefully.""" env_vars = {"ACE_API_URL": "", "ACE_API_TOKEN": "", "AUGMENT_SESSION_AUTH": ""} with patch("pathlib.Path.home", return_value=tmp_path), patch.dict(os.environ, env_vars, clear=False): client = AceToolClient() assert client.base_url == "" assert client.token == "" assert client.auth_source == "none" def test_auth_source_in_get_config(self, tmp_path): """Test get_config() includes auth_source field.""" session_file = tmp_path / ".augment" / "session.json" session_file.parent.mkdir(parents=True) session_data = {"accessToken": "token", "tenantURL": "https://example.com/"} session_file.write_text(json.dumps(session_data), encoding="utf-8") with patch("pathlib.Path.home", return_value=tmp_path): client = AceToolClient() config = client.get_config() assert "auth_source" in config assert config["auth_source"] == "session.json" -
test_client_endpoints.py 4.1 KB
"""Endpoint routing, third-party detection, hard errors, and config output.""" import os from unittest.mock import patch import pytest from client import AceToolClient class TestEndpointResolution: """Resolution order: PROMPT_ENHANCER_ENDPOINT > ACE_ENHANCER_ENDPOINT > constructor > default.""" def test_env_overrides_constructor(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "claude", "ACE_ENHANCER_ENDPOINT": "", }, clear=False, ): c = AceToolClient(endpoint="openai") assert c.endpoint == "claude" def test_new_env_wins_over_legacy_and_constructor(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "claude", "ACE_ENHANCER_ENDPOINT": "openai", }, clear=False, ): c = AceToolClient(endpoint="gemini") assert c.endpoint == "claude" def test_legacy_env_used_when_new_absent(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "gemini", }, clear=False, ): c = AceToolClient() assert c.endpoint == "gemini" def test_constructor_arg_used_when_no_env(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", }, clear=False, ): c = AceToolClient(endpoint="openai") assert c.endpoint == "openai" def test_default_when_nothing_set(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", }, clear=False, ): c = AceToolClient() assert c.endpoint == "new" class TestIsThirdParty: def _client(self, endpoint): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", }, clear=False, ): return AceToolClient(endpoint=endpoint) def test_codex_is_third_party(self): assert self._client("codex")._is_third_party() is True def test_claude_is_third_party(self): assert self._client("claude")._is_third_party() is True def test_new_is_not_third_party(self): assert self._client("new")._is_third_party() is False class TestHardError: """Unconfigured third-party endpoints raise immediately, no silent fallback.""" def _assert_raises(self, endpoint): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_BASE_URL": "", "PROMPT_ENHANCER_TOKEN": "", }, clear=False, ): c = AceToolClient(endpoint=endpoint) with pytest.raises(ValueError, match="PROMPT_ENHANCER_BASE_URL"): c.enhance_prompt("test", "") def test_unconfigured_claude_raises(self): self._assert_raises("claude") def test_unconfigured_codex_raises(self): self._assert_raises("codex") class TestGetConfigExtended: def test_get_config_has_extended_fields(self): with patch.dict( os.environ, { "ACE_API_URL": "http://test", "ACE_API_TOKEN": "tok", "PROMPT_ENHANCER_ENDPOINT": "claude", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT": "1", "PROMPT_ENHANCER_BASE_URL": "http://third", "PROMPT_ENHANCER_TOKEN": "tp", }, clear=False, ): config = AceToolClient().get_config() assert "endpoint_effective" in config assert "endpoint_env_ready" in config assert "search_context_injection" in config -
test_codex_endpoint.py 3.2 KB
"""Codex endpoint: Responses API output parsing and model resolution.""" import os from unittest.mock import patch import pytest from client import AceToolClient class TestExtractCodexOutputText: def _extract(self, api_response): return AceToolClient._extract_codex_output_text(api_response) def test_final_answer_priority(self): resp = { "output": [ {"type": "message", "phase": "thinking", "content": [{"type": "output_text", "text": "thinking text"}]}, { "type": "message", "phase": "final_answer", "content": [{"type": "output_text", "text": "final text"}], }, ] } assert self._extract(resp) == "final text" def test_multi_part_concat(self): resp = { "output": [ { "type": "message", "phase": "final_answer", "content": [ {"type": "output_text", "text": "part1"}, {"type": "output_text", "text": "part2"}, ], }, ] } assert self._extract(resp) == "part1\npart2" def test_refusal_raises(self): resp = { "output": [ {"type": "message", "content": [{"type": "refusal", "refusal": "I cannot do that"}]}, ] } with pytest.raises(RuntimeError, match="Codex API refusal"): self._extract(resp) def test_refusal_plus_text_returns_text(self): resp = { "output": [ { "type": "message", "content": [ {"type": "refusal", "refusal": "refused"}, {"type": "output_text", "text": "actual output"}, ], }, ] } assert self._extract(resp) == "actual output" def test_no_output_raises(self): with pytest.raises(RuntimeError, match="no output_text"): self._extract({"output": []}) def test_empty_text_ignored(self): resp = { "output": [ { "type": "message", "content": [ {"type": "output_text", "text": " "}, {"type": "output_text", "text": "real"}, ], }, ] } assert self._extract(resp) == "real" def test_null_content_raises(self): resp = {"output": [{"type": "message", "content": None}]} with pytest.raises(RuntimeError, match="no output_text"): self._extract(resp) class TestCodexModelResolution: def test_codex_default_model(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_MODEL": "", }, clear=False, ): c = AceToolClient(endpoint="codex") from templates import DEFAULT_CODEX_MODEL assert c._get_third_party_model() == DEFAULT_CODEX_MODEL -
test_indexer_ignore.py 1.8 KB
"""Indexer ignore patterns: .gitignore + .aceignore merge behavior.""" from indexer import Indexer class TestLoadIgnorePatterns: def test_reads_gitignore(self, tmp_path): (tmp_path / ".gitignore").write_text("*.pyc\n__pycache__\n", encoding="utf-8") idx = Indexer(str(tmp_path), "http://fake.url", "fake-token") assert "*.pyc" in idx._gitignore_patterns assert "__pycache__" in idx._gitignore_patterns def test_reads_aceignore(self, tmp_path): (tmp_path / ".aceignore").write_text("secret/\n*.log\n", encoding="utf-8") idx = Indexer(str(tmp_path), "http://fake.url", "fake-token") assert "secret/" in idx._gitignore_patterns assert "*.log" in idx._gitignore_patterns def test_merges_both_files(self, tmp_path): (tmp_path / ".gitignore").write_text("*.pyc\n", encoding="utf-8") (tmp_path / ".aceignore").write_text("*.log\n", encoding="utf-8") idx = Indexer(str(tmp_path), "http://fake.url", "fake-token") assert "*.pyc" in idx._gitignore_patterns assert "*.log" in idx._gitignore_patterns def test_skips_comments_and_empty_lines(self, tmp_path): (tmp_path / ".aceignore").write_text("# comment\n\n \nvalid_pattern\n", encoding="utf-8") idx = Indexer(str(tmp_path), "http://fake.url", "fake-token") assert "valid_pattern" in idx._gitignore_patterns assert "# comment" not in idx._gitignore_patterns def test_no_files_empty_patterns(self, tmp_path): idx = Indexer(str(tmp_path), "http://fake.url", "fake-token") assert idx._gitignore_patterns == [] def test_aceignore_only(self, tmp_path): (tmp_path / ".aceignore").write_text("build/\n", encoding="utf-8") idx = Indexer(str(tmp_path), "http://fake.url", "fake-token") assert "build/" in idx._gitignore_patterns -
test_reasoning_effort.py 3.9 KB
"""Reasoning effort control: resolution order and per-endpoint payload injection.""" import os from unittest.mock import patch from client import AceToolClient _BASE_ENV = { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_BASE_URL": "https://h", "PROMPT_ENHANCER_TOKEN": "t", "PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT": "", "PROMPT_ENHANCER_REASONING_EFFORT": "", } def _make(endpoint, effort_env=None, effort_arg="__unset__"): env = dict(_BASE_ENV) if effort_env is None: env.pop("PROMPT_ENHANCER_REASONING_EFFORT") else: env["PROMPT_ENHANCER_REASONING_EFFORT"] = effort_env with patch.dict(os.environ, env, clear=False): kwargs = {} if effort_arg == "__unset__" else {"reasoning_effort": effort_arg} return AceToolClient(endpoint=endpoint, **kwargs) def _captured(client, endpoint): """Call the endpoint-specific API with a stubbed _post_json; return its payload.""" responses = { "openai": {"choices": [{"message": {"content": "<augment-enhanced-prompt>x</augment-enhanced-prompt>"}}]}, "codex": {"output": [{"type": "message", "content": [{"type": "output_text", "text": "x"}]}]}, "claude": {"content": [{"type": "text", "text": "x"}]}, "gemini": {"candidates": [{"content": {"parts": [{"text": "x"}]}}]}, } captured = {} def fake_post(url, payload, **kw): captured.update(payload) return responses[endpoint] client._post_json = fake_post getattr(client, f"_call_{endpoint}_api")("test prompt", [], client._get_third_party_model()) return captured class TestEffortResolution: def test_default_is_none(self): assert _make("openai").reasoning_effort == "none" def test_env_override_normalized(self): assert _make("openai", effort_env=" HIGH ").reasoning_effort == "high" def test_constructor_wins_over_env(self): assert _make("openai", effort_env="high", effort_arg="low").reasoning_effort == "low" def test_empty_env_sends_nothing(self): assert _make("openai", effort_env="").reasoning_effort == "" class TestPayloadInjection: def test_openai_default(self): p = _captured(_make("openai"), "openai") assert p["reasoning_effort"] == "none" assert p["max_tokens"] == 4096 def test_openai_explicit(self): p = _captured(_make("openai", effort_env="high"), "openai") assert p["reasoning_effort"] == "high" def test_openai_empty_omits_key(self): p = _captured(_make("openai", effort_env=""), "openai") assert "reasoning_effort" not in p def test_codex_default(self): p = _captured(_make("codex"), "codex") assert p["reasoning"] == {"effort": "none"} def test_codex_empty_omits_key(self): p = _captured(_make("codex", effort_env=""), "codex") assert "reasoning" not in p def test_claude_default_disabled(self): p = _captured(_make("claude"), "claude") assert p["thinking"] == {"type": "disabled"} assert "output_config" not in p def test_claude_explicit_adaptive_with_effort(self): p = _captured(_make("claude", effort_env="high"), "claude") assert p["thinking"] == {"type": "adaptive"} assert p["output_config"] == {"effort": "high"} def test_claude_empty_omits_key(self): p = _captured(_make("claude", effort_env=""), "claude") assert "thinking" not in p def test_gemini_none_maps_to_minimal(self): p = _captured(_make("gemini"), "gemini") assert p["generationConfig"]["thinkingConfig"] == {"thinkingLevel": "minimal"} def test_gemini_explicit_level(self): p = _captured(_make("gemini", effort_env="low"), "gemini") assert p["generationConfig"]["thinkingConfig"] == {"thinkingLevel": "low"} def test_gemini_empty_omits_key(self): p = _captured(_make("gemini", effort_env=""), "gemini") assert "thinkingConfig" not in p["generationConfig"] -
test_search_context.py 4.4 KB
"""Search context injection: env toggle, normalization, prompt wrapping, guards.""" import os from unittest.mock import patch import pytest from client import AceToolClient class TestShouldIncludeSearchContext: def _check(self, env_val, expected): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT": env_val, }, clear=False, ): assert AceToolClient()._should_include_search_context() is expected def test_true_values(self): for val in ("1", "true", "yes", "on", " True ", " YES ", " ON "): self._check(val, True) def test_false_values(self): for val in ("0", "false", "no", "off", "", " "): self._check(val, False) class TestNormalizeSearchContext: def _normalize(self, text): return AceToolClient._normalize_search_context(text) def test_empty_returns_placeholder(self): from templates import NO_RELEVANT_CODE_CONTEXT assert self._normalize("") == NO_RELEVANT_CODE_CONTEXT def test_whitespace_returns_placeholder(self): from templates import NO_RELEVANT_CODE_CONTEXT assert self._normalize(" ") == NO_RELEVANT_CODE_CONTEXT def test_normal_text_passthrough(self): assert self._normalize("some code context") == "some code context" def test_exact_limit_no_truncation(self): from templates import SEARCH_CONTEXT_CHAR_LIMIT result = self._normalize("a" * SEARCH_CONTEXT_CHAR_LIMIT) assert len(result) == SEARCH_CONTEXT_CHAR_LIMIT assert "[codebase_context truncated" not in result def test_over_limit_truncated(self): from templates import SEARCH_CONTEXT_CHAR_LIMIT result = self._normalize("a" * (SEARCH_CONTEXT_CHAR_LIMIT + 1)) assert "[codebase_context truncated for length]" in result class TestBuildPromptWithSearchContext: def _build(self, original, ctx): return AceToolClient._build_prompt_with_search_context(original, ctx) def test_contains_codebase_context_tag(self): result = self._build("my prompt", "some context") assert "<codebase_context>" in result assert "</codebase_context>" in result def test_contains_original_request_tag(self): result = self._build("my prompt", "some context") assert "<original_request>" in result assert "</original_request>" in result def test_original_and_context_preserved(self): result = self._build("my prompt", "some context") assert "my prompt" in result assert "some context" in result class TestMaybeInjectSearchContext: def test_missing_project_root_raises(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "claude", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT": "1", "PROMPT_ENHANCER_BASE_URL": "https://api.example.com", "PROMPT_ENHANCER_TOKEN": "tok", "ACE_API_URL": "https://ace.example.com", "ACE_API_TOKEN": "ace-tok", }, clear=False, ): c = AceToolClient(endpoint="claude") with pytest.raises(ValueError, match="project"): c._maybe_inject_search_context("claude", "test prompt", None) def test_non_third_party_returns_original(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT": "1", }, clear=False, ): c = AceToolClient(endpoint="new") assert c._maybe_inject_search_context("new", "test prompt", "/some/path") == "test prompt" def test_disabled_returns_original(self): with patch.dict( os.environ, { "PROMPT_ENHANCER_ENDPOINT": "claude", "ACE_ENHANCER_ENDPOINT": "", "PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT": "0", }, clear=False, ): c = AceToolClient(endpoint="claude") assert c._maybe_inject_search_context("claude", "test prompt", "/some/path") == "test prompt" -
test_url_and_400error.py 14.7 KB
"""Regression tests for diagnose fixes: URL consistency, unknown-blobs self-heal, cache hierarchy.""" import gzip import json import os import shutil import sys from pathlib import Path from types import SimpleNamespace import httpx import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) import ace_cli # noqa: E402 from client import AceToolClient, build_api_url # noqa: E402 from indexer import Indexer, IndexRebuildError, ProjectIndex # noqa: E402 from templates import INDEX_DIR, INDEX_FILE # noqa: E402 from utils import build_api_url as utils_build_api_url # noqa: E402 def _write_index(root: Path): p = root / INDEX_DIR / INDEX_FILE p.parent.mkdir(parents=True, exist_ok=True) with gzip.open(p, "wt", encoding="utf-8") as f: json.dump({"entries": {}, "last_indexed": 1.0}, f) return p # --- Problem 1: URL construction consistency --- def test_build_api_url_lives_in_utils_and_reexported(): assert build_api_url is utils_build_api_url @pytest.mark.parametrize( "base,path,expected", [ ("https://api.example.com", "/v1/messages", "https://api.example.com/v1/messages"), ("https://api.example.com/v1", "/v1/messages", "https://api.example.com/v1/messages"), ("https://proxy.com/v1beta", "/v1/messages", "https://proxy.com/v1beta/messages"), ("https://api.example.com/vertex", "/v1/messages", "https://api.example.com/vertex/v1/messages"), ("https://api.example.com", "v1/messages", "https://api.example.com/v1/messages"), ("https://api.example.com/v1/", "/v1/messages", "https://api.example.com/v1/messages"), ("https://h/v1", "/batch-upload", "https://h/v1/batch-upload"), ], ) def test_build_api_url_version_handling(base, path, expected): assert build_api_url(base, path) == expected def test_upload_url_uses_build_api_url(tmp_path, monkeypatch): captured = {} class FakeResp: status_code = 200 def raise_for_status(self): pass class FakeClient: def __init__(self, *a, **k): pass def __enter__(self): return self def __exit__(self, *a): return False def post(self, url, **kw): captured["url"] = url return FakeResp() monkeypatch.setattr("indexer.httpx.Client", FakeClient) idx = Indexer(str(tmp_path), "https://h/v1", "tok") assert idx._upload_batch_with_retry({}, {"blobs": []}) assert captured["url"] == build_api_url("https://h/v1", "/batch-upload") # --- Problem 2: 400 unknown blobs self-heal --- def test_unknown_blobs_triggers_rebuild_and_single_retry(monkeypatch): c = AceToolClient(base_url="https://h", token="tok") calls = {"post": 0, "rebuild": 0} monkeypatch.setattr("client.Indexer.__init__", lambda self, *a, **k: None) monkeypatch.setattr("client.Indexer.get_blob_names", lambda self: ["stale"]) def fake_rebuild(self): calls["rebuild"] += 1 return ["fresh"] monkeypatch.setattr("client.Indexer.force_rebuild", fake_rebuild, raising=False) def fake_post(url, payload, *, headers, provider="API", timeout=None): calls["post"] += 1 if calls["post"] == 1: resp = httpx.Response(400, text="unknown blobs: ['stale']") raise httpx.HTTPStatusError("400 Bad Request", request=None, response=resp) assert payload["blobs"]["added_blobs"] == ["fresh"] return {"formatted_retrieval": "ok"} monkeypatch.setattr(c, "_post_json", fake_post) result = c._remote_search("/proj", "query") assert result["results"] == "ok" assert calls["rebuild"] == 1 assert calls["post"] == 2 def test_unknown_blobs_no_infinite_retry(monkeypatch): c = AceToolClient(base_url="https://h", token="tok") calls = {"post": 0} monkeypatch.setattr("client.Indexer.__init__", lambda self, *a, **k: None) monkeypatch.setattr("client.Indexer.get_blob_names", lambda self: ["stale"]) monkeypatch.setattr("client.Indexer.force_rebuild", lambda self: ["fresh"], raising=False) def fake_post(url, payload, *, headers, provider="API", timeout=None): calls["post"] += 1 resp = httpx.Response(400, text="unknown blobs") raise httpx.HTTPStatusError("400 Bad Request", request=None, response=resp) monkeypatch.setattr(c, "_post_json", fake_post) with pytest.raises(httpx.HTTPStatusError): c._remote_search("/proj", "query") assert calls["post"] == 2 def test_other_400_errors_not_swallowed(monkeypatch): c = AceToolClient(base_url="https://h", token="tok") calls = {"post": 0, "rebuild": 0} monkeypatch.setattr("client.Indexer.__init__", lambda self, *a, **k: None) monkeypatch.setattr("client.Indexer.get_blob_names", lambda self: ["a"]) monkeypatch.setattr( "client.Indexer.force_rebuild", lambda self: calls.__setitem__("rebuild", calls["rebuild"] + 1) or [], raising=False, ) def fake_post(url, payload, *, headers, provider="API", timeout=None): calls["post"] += 1 resp = httpx.Response(400, text="malformed request") raise httpx.HTTPStatusError("400 Bad Request", request=None, response=resp) monkeypatch.setattr(c, "_post_json", fake_post) with pytest.raises(httpx.HTTPStatusError): c._remote_search("/proj", "query") assert calls["post"] == 1 assert calls["rebuild"] == 0 # --- Problem 3: cache hierarchy --- def test_child_inherits_nearest_ancestor_cache(tmp_path): parent = tmp_path / "parent" child = parent / "sub" child.mkdir(parents=True) _write_index(parent) idx = Indexer(str(child), "https://h", "tok") assert idx.root == parent.resolve() assert idx.index_path == parent.resolve() / INDEX_DIR / INDEX_FILE def test_own_cache_preferred_over_ancestor(tmp_path): parent = tmp_path / "p2" child = parent / "sub" child.mkdir(parents=True) _write_index(parent) _write_index(child) idx = Indexer(str(child), "https://h", "tok") assert idx.root == child.resolve() def test_no_cache_anywhere_uses_project_root(tmp_path): proj = tmp_path / "fresh" proj.mkdir() idx = Indexer(str(proj), "https://h", "tok") assert idx.root == proj.resolve() def test_nearest_ancestor_wins_over_farther(tmp_path): outer = tmp_path / "repo" nearest = outer / "packages" child = nearest / "service" child.mkdir(parents=True) _write_index(outer) _write_index(nearest) idx = Indexer(str(child), "https://h", "tok") assert idx.root == nearest.resolve() def test_home_cache_not_inherited(tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) _write_index(tmp_path) proj = tmp_path / "work" / "proj" proj.mkdir(parents=True) idx = Indexer(str(proj), "https://h", "tok") assert idx.root == proj.resolve() def test_parent_indexing_absorbs_child_cache(tmp_path): root = tmp_path / "proj" sub = root / "pkg" sub.mkdir(parents=True) (root / "m.py").write_text("a = 1\n", encoding="utf-8") (sub / "n.py").write_text("b = 2\n", encoding="utf-8") _write_index(root) _write_index(sub) idx = Indexer(str(root), "", "") # empty base_url: upload step is a no-op names = idx.get_blob_names() assert not (sub / INDEX_DIR).exists() assert (root / INDEX_DIR / INDEX_FILE).exists() assert len(names) == 2 # --- Review fixes: absorb boundaries, retry amplification, CLI transparency --- def test_child_cache_kept_when_root_never_saves_index(tmp_path): root = tmp_path / "empty-root" sub = root / "pkg" sub.mkdir(parents=True) _write_index(sub) # only a child cache, no eligible source files anywhere idx = Indexer(str(root), "", "") names = idx.get_blob_names() assert names == [] assert not (root / INDEX_DIR / INDEX_FILE).exists() assert (sub / INDEX_DIR / INDEX_FILE).exists() def test_ignored_subtree_cache_not_absorbed(tmp_path): root = tmp_path / "proj2" ignored = root / "legacy" ignored.mkdir(parents=True) (root / "m.py").write_text("a = 1\n", encoding="utf-8") (ignored / "old.py").write_text("z = 9\n", encoding="utf-8") (root / ".gitignore").write_text("legacy/\n", encoding="utf-8") _write_index(ignored) idx = Indexer(str(root), "", "") names = idx.get_blob_names() assert (ignored / INDEX_DIR / INDEX_FILE).exists() # not covered by parent scan assert len(names) == 1 # only m.py def test_force_rebuild_raises_on_upload_failure(tmp_path, monkeypatch): (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") idx = Indexer(str(tmp_path), "https://h", "tok") monkeypatch.setattr(idx, "_upload_pending", lambda: False) with pytest.raises(IndexRebuildError): idx.force_rebuild() def test_rebuild_failure_not_retried_by_enhance(monkeypatch): monkeypatch.delenv("PROMPT_ENHANCER_ENDPOINT", raising=False) monkeypatch.delenv("ACE_ENHANCER_ENDPOINT", raising=False) c = AceToolClient(base_url="https://h", token="tok", endpoint="claude") calls = {"n": 0} def fake_third_party(prompt, history, project_root=None): calls["n"] += 1 raise IndexRebuildError("rebuild upload failed") monkeypatch.setattr(c, "_call_third_party_api", fake_third_party) with pytest.raises(IndexRebuildError): c.enhance_prompt("p", "", "/proj") assert calls["n"] == 1 # ValueError subclass: excluded from tenacity retry def test_cmd_index_reports_effective_root(tmp_path, monkeypatch, capsys): parent = tmp_path / "top" child = parent / "sub" child.mkdir(parents=True) _write_index(parent) monkeypatch.setattr( ace_cli, "AceToolClient", lambda *a: SimpleNamespace(base_url="https://h", token="tok"), ) def fake_names(self): self._index = ProjectIndex() return [] monkeypatch.setattr(ace_cli.Indexer, "get_blob_names", fake_names) ace_cli.cmd_index(SimpleNamespace(api_url=None, token=None, endpoint=None, project_root=str(child))) out = json.loads(capsys.readouterr().out) assert out["effective_root"] == str(parent.resolve()) assert out["project_root"] == str(child) # --- Concurrency race: tolerate-and-converge (absorb vs concurrent child save) --- def test_save_index_tolerates_dir_deleted_mid_write(tmp_path, monkeypatch): (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") idx = Indexer(str(tmp_path), "", "") idx._load_index() idx._scan_and_update() real_mkdir = Path.mkdir def mkdir_then_vanish(self, *a, **k): real_mkdir(self, *a, **k) shutil.rmtree(self) # concurrent ancestor absorbs the dir right after mkdir monkeypatch.setattr(Path, "mkdir", mkdir_then_vanish) assert idx._save_index() is False # abandoned, not crashed assert not idx.index_path.exists() def test_save_index_returns_true_on_success(tmp_path): (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") idx = Indexer(str(tmp_path), "", "") idx._load_index() idx._scan_and_update() assert idx._save_index() is True assert idx.index_path.is_file() def test_no_absorb_when_save_abandoned(tmp_path, monkeypatch): root = tmp_path / "proj3" sub = root / "pkg" sub.mkdir(parents=True) (root / "m.py").write_text("a = 1\n", encoding="utf-8") (sub / "n.py").write_text("b = 2\n", encoding="utf-8") _write_index(root) _write_index(sub) idx = Indexer(str(root), "", "") monkeypatch.setattr(idx, "_save_index", lambda: False) # own cache absorbed by grandparent idx.get_blob_names() assert (sub / INDEX_DIR / INDEX_FILE).exists() # no cascading deletion def test_force_rebuild_skips_absorb_when_save_abandoned(tmp_path, monkeypatch): (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") sub = tmp_path / "pkg" sub.mkdir() (sub / "n.py").write_text("b = 2\n", encoding="utf-8") _write_index(sub) idx = Indexer(str(tmp_path), "", "") monkeypatch.setattr(idx, "_save_index", lambda: False) names = idx.force_rebuild() # upload is a no-op (empty base_url) -> success assert names # in-memory result still returned assert (sub / INDEX_DIR / INDEX_FILE).exists() def test_absorb_skips_child_on_rmtree_failure(tmp_path, monkeypatch): root = tmp_path / "proj4" sub = root / "pkg" sub.mkdir(parents=True) (root / "m.py").write_text("a = 1\n", encoding="utf-8") (sub / "n.py").write_text("b = 2\n", encoding="utf-8") _write_index(root) _write_index(sub) def locked_rmtree(p): raise PermissionError(13, "file in use") # Windows open-handle / racy delete monkeypatch.setattr("indexer.shutil.rmtree", locked_rmtree) idx = Indexer(str(root), "", "") names = idx.get_blob_names() # must not raise assert (sub / INDEX_DIR / INDEX_FILE).exists() # skipped, retried next run assert len(names) == 2 def test_save_index_tmp_is_per_process(tmp_path, monkeypatch): captured = {} real_replace = Path.replace def spy_replace(self, target): captured["tmp"] = self.name return real_replace(self, target) monkeypatch.setattr(Path, "replace", spy_replace) (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") idx = Indexer(str(tmp_path), "", "") idx._load_index() idx._scan_and_update() assert idx._save_index() is True assert captured["tmp"] == f"{INDEX_FILE}.{os.getpid()}.tmp" # no shared-name torn writes assert list((tmp_path / INDEX_DIR).glob("*.tmp")) == [] # renamed away, no litter def test_save_index_retries_transient_replace_permission_error(tmp_path, monkeypatch): attempts = {"n": 0} real_replace = Path.replace def briefly_locked_replace(self, target): attempts["n"] += 1 if attempts["n"] == 1: raise PermissionError(5, "target open in a concurrent reader") # WinError 5 return real_replace(self, target) monkeypatch.setattr(Path, "replace", briefly_locked_replace) monkeypatch.setattr("indexer.time.sleep", lambda s: None) (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") idx = Indexer(str(tmp_path), "", "") idx._load_index() idx._scan_and_update() assert idx._save_index() is True assert attempts["n"] == 2 assert idx.index_path.is_file() def test_save_index_propagates_persistent_permission_error(tmp_path, monkeypatch): def always_locked_replace(self, target): raise PermissionError(5, "acl denies write") monkeypatch.setattr(Path, "replace", always_locked_replace) monkeypatch.setattr("indexer.time.sleep", lambda s: None) (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8") idx = Indexer(str(tmp_path), "", "") idx._load_index() idx._scan_and_update() with pytest.raises(PermissionError): # genuine ACL problems must surface idx._save_index() -
__init__.py 0 B
-
-
.env.example 2.5 KB · in bundle
-
README.md 11.8 KB
# ACE-Tool CLI Semantic code search, incremental code indexing, and AI-powered prompt enhancement. MCP-independent CLI for codebase navigation and requirement clarification. ## Features - **Code Indexing**: Incremental scan, hash (SHA-256), chunk, and upload code blobs to ACE service - **Remote Search**: Semantic codebase retrieval via `POST /agents/codebase-retrieval` with local fallback - **Prompt Enhancement**: AI-powered prompt refinement with interactive web UI - **Cloud Context Injection**: All endpoints (old, new, third-party) inject retrieval context when `--project-root` is provided - **Multiple Backends**: Support for Augment (`new`/`old`), Claude, OpenAI, Gemini, and Codex APIs - **Search Context Injection**: Optional codebase context injection for third-party endpoints via XML tags - **`.aceignore` Support**: Project-level ignore patterns merged with `.gitignore` for indexing - **Local Fallback**: Works offline with keyword-based search ## Installation ```bash pip install httpx tenacity ``` ## Quick Start ### Authentication Setup **Recommended: Use session.json (compatible with auggie CLI)** ```bash # If you have auggie CLI installed, just login: auggie login # This creates ~/.augment/session.json with your credentials ``` **Alternative: Environment Variable (for CI/CD)** ```bash export AUGMENT_SESSION_AUTH='{"accessToken":"your-token","tenantURL":"https://api.example.com/"}' ``` **Legacy Method (deprecated but still supported)** ```bash export ACE_API_URL="https://your-api-endpoint.com" export ACE_API_TOKEN="your-token-here" ``` ### Basic Usage ```bash # Index project (scan, hash, upload code blobs) python scripts/ace_cli.py index -p . # Search codebase (remote retrieval if API configured, else local fallback) python scripts/ace_cli.py search_context -p . -q "user authentication handler" # Enhance prompt (opens interactive web UI) python scripts/ace_cli.py enhance_prompt -p "implement login feature" --project-root . # Enhance prompt (non-interactive, JSON output) python scripts/ace_cli.py enhance_prompt --no-interactive -p "implement login feature" --project-root . # Enhance with codex endpoint python scripts/ace_cli.py --endpoint codex enhance_prompt -p "implement feature" # Show configuration (check auth_source to verify authentication method) python scripts/ace_cli.py get_config ``` ## CLI Commands ### Indexing | Command | Description | |---------|-------------| | `index -p <path>` | Index project: scan, hash, chunk, upload blobs | ### Search Operations | Command | Description | |---------|-------------| | `search_context -p <path> -q <query>` | Search codebase with natural language | ### Enhancement Operations | Command | Description | |---------|-------------| | `enhance_prompt -p <prompt>` | Enhance prompt (interactive UI) | | `enhance_prompt --no-interactive -p <prompt>` | Enhance prompt (JSON output) | | `enhance_prompt -H <history> -p <prompt>` | Enhance with conversation history | | `enhance_prompt --project-root <path> -p <prompt>` | Enhance with cloud code context | ### Configuration | Command | Description | |---------|-------------| | `get_config` | Show current configuration | ## Global Options ```bash python scripts/ace_cli.py [OPTIONS] <command> Options: --endpoint TYPE API endpoint: new, old, claude, openai, gemini, codex --api-url URL Override API base URL --token TOKEN Override API token ``` ## Endpoint Architecture ### Supported Endpoints | Endpoint | API Path | Default Model | Type | Status | |----------|----------|---------------|------|--------| | `new` | `/prompt-enhancer` | `claude-sonnet-4-5` | Augment | ⚠️ Currently unavailable | | `old` | `/chat-stream` (SSE) | `claude-sonnet-4-5` | Augment | ⚠️ Currently unavailable | | `claude` | `/v1/messages` | `sonnet-4-6-20250929` | Third-party | ✅ Available | | `openai` | `/v1/chat/completions` | `gpt-5.4` | Third-party | ✅ Available | | `gemini` | `/v1beta/models/{model}:generateContent` | `gemini-3-flash-preview` | Third-party | ✅ Available | | `codex` | `/v1/responses` | `gpt-5.4` | Third-party | ✅ Available | > **Note**: The official Augment endpoints (`new` and `old`) are currently experiencing service issues. Please use third-party endpoints (Claude, OpenAI, Gemini, or Codex) for prompt enhancement features. ### Endpoint Resolution Priority order (highest wins): 1. `PROMPT_ENHANCER_ENDPOINT` env var 2. `ACE_ENHANCER_ENDPOINT` env var (legacy fallback) 3. `--endpoint` CLI flag 4. Default: `new` ### Codex Endpoint The `codex` endpoint routes to OpenAI's Responses API (`/v1/responses`). It uses the `input`/`output` array format instead of the Chat Completions `messages` format. Response parsing handles `output_text` content parts, `final_answer` phase priority, and refusal detection. ```bash export PROMPT_ENHANCER_ENDPOINT=codex export PROMPT_ENHANCER_BASE_URL=https://api.openai.com export PROMPT_ENHANCER_TOKEN=sk-... python scripts/ace_cli.py enhance_prompt -p "implement feature" ``` ### URL Construction All HTTP calls use `build_api_url(base_url, path)` which handles version prefix deduplication (e.g., `https://api.example.com/v1` + `/v1/messages` → `https://api.example.com/v1/messages`, not `.../v1/v1/messages`). ## Configuration ### Authentication ACE-Tool supports multiple authentication methods with the following priority: 1. **Constructor parameters** (highest priority, programmatic use only) 2. **`~/.augment/session.json`** (recommended, created by `auggie login`) 3. **`AUGMENT_SESSION_AUTH`** (CI/CD and headless environments) 4. **Legacy `ACE_API_*`** (deprecated, backward compatibility only) **Method 1: session.json (Recommended)** Use `auggie login` to create `~/.augment/session.json`: ```json { "accessToken": "your-token-here", "tenantURL": "https://api.example.com/", "scopes": ["email"] } ``` **Method 2: AUGMENT_SESSION_AUTH (CI/CD)** ```bash export AUGMENT_SESSION_AUTH='{"accessToken":"your-token","tenantURL":"https://api.example.com/"}' ``` **Method 3: Legacy Environment Variables (Deprecated)** ```bash export ACE_API_URL=https://your-augment-api.com export ACE_API_TOKEN=your-augment-token ``` ⚠️ **Note**: `ACE_API_URL` and `ACE_API_TOKEN` are deprecated but still supported for backward compatibility. New projects should use `session.json` or `AUGMENT_SESSION_AUTH`. **Verify Configuration** Use `get_config` to check current authentication source: ```bash python scripts/ace_cli.py get_config # Output includes: "auth_source": "session.json" | "AUGMENT_SESSION_AUTH" | "ACE_API_TOKEN" | "none" ``` ### Environment Variables | Variable | Description | |----------|-------------| | `AUGMENT_SESSION_AUTH` | JSON string with `accessToken` and `tenantURL` (new format, recommended for CI/CD) | | `ACE_API_URL` | ⚠️ **Deprecated** - Augment API base URL (use session.json or AUGMENT_SESSION_AUTH instead) | | `ACE_API_TOKEN` | ⚠️ **Deprecated** - Augment API token (use session.json or AUGMENT_SESSION_AUTH instead) | | `PROMPT_ENHANCER_ENDPOINT` | Endpoint type override (takes precedence over `ACE_ENHANCER_ENDPOINT`) | | `ACE_ENHANCER_ENDPOINT` | Legacy endpoint override (fallback) | | `PROMPT_ENHANCER_BASE_URL` | Third-party API base URL | | `PROMPT_ENHANCER_TOKEN` | Third-party API key | | `PROMPT_ENHANCER_MODEL` | Model override for third-party endpoints | | `PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT` | Enable search context injection (`1`, `true`, `yes`, `on`) | ### .env File Create `.env` in the project root (see `.env.example`): ```bash # Recommended: Use ~/.augment/session.json (created by `auggie login`) # or set AUGMENT_SESSION_AUTH for CI/CD # Legacy format (deprecated) # ACE_API_URL=https://your-augment-api.com # ACE_API_TOKEN=your-augment-token PROMPT_ENHANCER_ENDPOINT=new # Third-party API (optional) # PROMPT_ENHANCER_BASE_URL=https://api.anthropic.com # PROMPT_ENHANCER_TOKEN=your-api-key # PROMPT_ENHANCER_MODEL=sonnet-4-6-20250929 # Search context injection (optional, for third-party endpoints) # PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT=1 ``` ## Search Context Injection When `PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT` is enabled and a third-party endpoint is used, the system: 1. Searches the codebase via ACE API using the original prompt as query 2. Normalizes the result (placeholder if empty, truncates at 12,000 chars) 3. Wraps search results in `<codebase_context>` XML tags 4. Wraps the original prompt in `<original_request>` XML tags 5. Sends the combined prompt to the third-party LLM Requirements: - `--project-root` must be provided (raises `ValueError` otherwise) - `ACE_API_URL` and `ACE_API_TOKEN` must be configured This is separate from the cloud retrieval context that Augment endpoints (`new`/`old`) inject automatically via `--project-root`. ## .aceignore Place a `.aceignore` file in the project root to exclude additional patterns from code indexing. Uses the same glob syntax as `.gitignore`. Patterns from both `.gitignore` and `.aceignore` are merged (union). Comments (`#`) and empty lines are skipped. ``` # .aceignore example test_fixtures/ *.generated.ts large_data/ node_modules/ .*/ logs/ tests/ ``` ## Indexing Details The `index` command performs incremental indexing: - **Scan**: Walks project files filtered by extension whitelist, binary blacklist, `.gitignore` + `.aceignore` patterns (with glob support), and `EXCLUDE_PATTERNS` - **Hash**: `SHA-256(path_bytes + content_bytes)` per blob - **Chunk**: Files >800 lines split as `file.py#chunk1of3` format - **Cache**: Incremental via `mtime + size` check; stored as `.ace-tool/index.json.gz` - **Hierarchy**: Nearest ancestor `.ace-tool` cache (lookup stops before home / filesystem root) is reused as the index root; child caches in subtrees covered by the root's scan are deleted after a successful index save - **Concurrency**: Lock-free tolerate-and-converge — a save interrupted by a concurrent absorption abandons persistence (the next run inherits the ancestor root); absorption skips child caches it cannot delete and retries on the next run - **Self-heal**: HTTP 400 `unknown blobs` on remote search triggers a full index rebuild + re-upload, then a single retry - **Upload**: Batch upload (≤30 blobs, ≤1MB per batch) to `POST /batch-upload` with retry (429 Retry-After, 5xx exponential backoff, 401/403 abort) - **Rollback**: Upload failure prevents index save, preserving previous valid state - **Encoding**: Multi-encoding detection chain (`utf-8 → gbk → gb18030 → cp1252`) ## Output Format All CLI output is JSON: ```json // Index result {"total_blobs": 42, "last_indexed": 1234567890.0, "project_root": "."} // Search result (remote) {"results": "formatted retrieval text...", "query": "...", "mode": "remote", "blob_count": 42} // Search result (local fallback) {"results": [{"file": "src/auth.py", "score": 5}], "query": "...", "mode": "local_fallback"} // Enhancement result {"enhanced_prompt": "..."} // Configuration {"base_url": "...", "endpoint": "new", "endpoint_effective": "new", "endpoint_env_ready": true, "token_configured": true, "third_party_configured": false, "search_context_injection": false} // Error {"error": "message", "status_code": 401} ``` ## Project Structure ``` skills/ace-tool/ ├── SKILL.md # Agent instructions ├── README.md # Developer documentation └── scripts/ ├── .env.example # Environment template ├── __init__.py ├── __main__.py # Module entry point ├── ace_cli.py # CLI entry point ├── client.py # API client (search, enhance, retrieval, all endpoints) ├── indexer.py # Code indexer (scan, hash, chunk, upload, .aceignore) ├── templates.py # Prompt templates and constants ├── utils.py # Utilities (encoding detection, content sanitization) └── web_ui.py # Interactive web UI ``` ## Acknowledgments - Based on [missdeer/ace-tool-rs](https://github.com/missdeer/ace-tool-rs) -
ruff.toml 656 B
# Lint and format baseline for this skill. # # Kept explicit rather than relying on ruff's default rule set: CI installs ruff # unpinned, so a future release that widens its defaults would otherwise turn # this job red without any change to the code. line-length = 120 target-version = "py311" [lint] select = [ "E", # pycodestyle errors "F", # pyflakes "W", # pycodestyle warnings "I", # isort "N", # pep8-naming "UP", # pyupgrade "B", # flake8-bugbear "A", # flake8-builtins "C4", # flake8-comprehensions "SIM", # flake8-simplify ] ignore = ["E501"] # line too long (handled by line-length above) -
SKILL.md 13 KB
--- name: ace-tool description: | Semantic codebase search, code indexing, and prompt enhancement via standalone CLI. Use when: (1) Semantic code search with natural language queries, (2) Code indexing for remote codebase retrieval, (3) Prompt enhancement with codebase context, (4) Before grep/find/glob operations for better accuracy, (5) Complex requirements clarification, (6) Large codebase navigation. Triggers: "search context", "enhance prompt", "find code that", "index project", "clarify requirements". IMPORTANT: Always use ace-tool BEFORE grep/find/glob for semantic-level code location. --- # ACE-Tool - Semantic Code Search & Prompt Enhancement High-performance semantic search, code indexing, and AI-powered prompt enhancement. Standalone CLI (no MCP dependency). ## Execution Methods ```bash # Prerequisites: pip install httpx tenacity # Environment: ACE_API_URL, ACE_API_TOKEN (optional for local fallback) # Index project for remote search (upload code blobs to ACE service) python scripts/ace_cli.py index -p /path/to/project # Search codebase with natural language (remote if API configured, else local fallback) python scripts/ace_cli.py search_context -p /path/to/project -q "function that handles authentication" # Enhance prompt (interactive mode - default, opens browser) python scripts/ace_cli.py enhance_prompt -p "implement login feature" -H "User: what auth method?\nAssistant: JWT" # Enhance prompt (non-interactive, JSON output) python scripts/ace_cli.py enhance_prompt --no-interactive -p "implement login feature" # Enhance prompt with project context (enables cloud retrieval for all endpoints) python scripts/ace_cli.py enhance_prompt -p "implement login feature" --project-root /path/to/project # Enhance prompt with specific endpoint python scripts/ace_cli.py --endpoint claude enhance_prompt -p "implement login feature" # Enhance prompt with codex endpoint python scripts/ace_cli.py --endpoint codex enhance_prompt -p "implement feature" # Check configuration python scripts/ace_cli.py get_config ``` ## Tool Routing Policy ### Prefer ACE-Tool Over Built-in Tools | Task | Avoid | Use ACE-Tool CLI | |------|-------|------------------| | Find function by purpose | `grep "def func"` | `search_context -q "function that..."` | | Locate feature code | `find . -name "*.py"` | `search_context -q "feature description"` | | Clarify requirements | Manual analysis | `enhance_prompt -p "requirement"` | | Understand code flow | Multiple grep/read | `search_context -q "flow description"` | | Index codebase | N/A | `index -p <project_root>` | ### When to Use Built-in Tools - Exact string matching (known identifiers) - File path patterns (known naming conventions) - Simple text replacement ## Command Reference ### index Index project files for remote codebase retrieval. Scans, hashes, chunks large files, and uploads to the ACE batch-upload API. Uses incremental indexing with gzip JSON cache at `.ace-tool/index.json.gz`. Respects both `.gitignore` and `.aceignore` patterns. If a parent directory already has a `.ace-tool` cache (lookup stops before home / filesystem root), it is reused as the effective index root; child caches in subtrees covered by the root's scan are absorbed (deleted) after a successful index save. Concurrent runs need no locks: a save interrupted by a concurrent absorption abandons persistence (the next run inherits the ancestor root), and absorption skips caches it cannot delete, retrying on the next run. A 400 `unknown blobs` response during remote search triggers an automatic index rebuild, re-upload, and a single retry. ```bash python scripts/ace_cli.py index -p <project_root> Options: -p, --project-root Project root path (required) ``` ### search_context Search codebase using natural language descriptions. Routes to remote API (`POST /agents/codebase-retrieval`) when configured, with automatic local keyword fallback. ```bash python scripts/ace_cli.py search_context -p <project_root> -q <query> Options: -p, --project-root Project root path (required) -q, --query Natural language query (required) ``` ### enhance_prompt Enhance prompts with codebase context and conversation history. All endpoints inject cloud retrieval context when `--project-root` is provided. Third-party endpoints additionally support search context injection via `PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT`. ```bash python scripts/ace_cli.py [--endpoint TYPE] enhance_prompt -p <prompt> [options] Global Options: --endpoint Endpoint type: new, old, claude, openai, gemini, codex (default: new) --api-url Override API base URL --token Override API token Command Options: -p, --prompt Original prompt (required) -H, --history Conversation history: "User: xxx\nAssistant: yyy" --history-file File containing conversation history --project-root Project root path (enables cloud retrieval context) --no-interactive Disable web UI, output JSON directly --no-browser Don't auto-open browser, just print URL --port Port for web server (default: 8765) ``` ### get_config Show current configuration status including endpoint resolution, env readiness, authentication source, and search context injection state. ```bash python scripts/ace_cli.py get_config ``` **Output fields:** - `base_url` - Currently configured API base URL - `endpoint` - Active enhancer endpoint (new/old/claude/openai/gemini/codex) - `endpoint_effective` - Resolved endpoint after env variable resolution - `endpoint_env_ready` - Whether required endpoint configuration is complete - `token_configured` - Whether authentication token is set - `third_party_configured` - Whether third-party endpoint config is complete - `auth_source` - Authentication source: `constructor` | `session.json` | `AUGMENT_SESSION_AUTH` | `ACE_API_TOKEN` | `none` - `search_context_injection` - Whether search context injection is enabled ## Interactive Enhancement Default mode opens web UI with actions: | Button | Action | |--------|--------| | **Regenerate** | Discard current, generate new enhancement from original prompt | | **Refine** | Iteratively improve current version, preserving your edits | | **Use Original** | Return the original prompt without enhancement | | **Send Enhanced** | Confirm and use the current enhanced prompt | | **Cancel** | Abort the enhancement process | **Keyboard Shortcuts:** `Ctrl+Enter` Send | `Esc` Cancel ## Endpoint Architecture ### Supported Endpoints | Endpoint | API Path | Default Model | Auth Header | Status | |----------|----------|---------------|-------------|--------| | `new` | `/prompt-enhancer` | `claude-sonnet-4-5` | `Bearer ACE_API_TOKEN` | ⚠️ Currently unavailable | | `old` | `/chat-stream` (SSE) | `claude-sonnet-4-5` | `Bearer ACE_API_TOKEN` | ⚠️ Currently unavailable | | `claude` | `/v1/messages` | `sonnet-4-6-20250929` | `x-api-key` | ✅ Available | | `openai` | `/v1/chat/completions` | `gpt-5.4` | `Bearer PROMPT_ENHANCER_TOKEN` | ✅ Available | | `gemini` | `/v1beta/models/{model}:generateContent` | `gemini-3-flash-preview` | `x-goog-api-key` | ✅ Available | | `codex` | `/v1/responses` | `gpt-5.4` | `Bearer PROMPT_ENHANCER_TOKEN` | ✅ Available | > **Note**: The official Augment endpoints (`new` and `old`) are currently experiencing service issues. Use third-party endpoints for prompt enhancement. ### Endpoint Resolution Order `PROMPT_ENHANCER_ENDPOINT` > `ACE_ENHANCER_ENDPOINT` (legacy) > `--endpoint` CLI flag > `new` (default) ### Third-Party Endpoints `claude`, `openai`, `gemini`, `codex` are third-party endpoints. They require: - `PROMPT_ENHANCER_BASE_URL` — API base URL - `PROMPT_ENHANCER_TOKEN` — API key/token - `PROMPT_ENHANCER_MODEL` — (optional) override default model Missing configuration raises `ValueError` immediately (hard error, no silent fallback). ### URL Construction All HTTP calls use `build_api_url(base_url, path)` which handles `/v1/`, `/v1beta/` version prefix deduplication. No hardcoded f-string URL construction. ## Environment Variables ### Authentication (Priority Order) ACE-Tool supports multiple authentication methods with the following priority: 1. **Constructor parameters** (highest priority, programmatic use only) 2. **`~/.augment/session.json`** (recommended, created by `auggie login`) 3. **`AUGMENT_SESSION_AUTH`** (CI/CD and headless environments) 4. **Legacy `ACE_API_*`** (deprecated, backward compatibility only) | Variable | Description | |----------|-------------| | `AUGMENT_SESSION_AUTH` | JSON string with `accessToken` and `tenantURL` (new format, recommended for CI/CD) | | `ACE_API_URL` | ⚠️ **Deprecated** - ACE API base URL (use session.json or AUGMENT_SESSION_AUTH instead) | | `ACE_API_TOKEN` | ⚠️ **Deprecated** - ACE API authentication token (use session.json or AUGMENT_SESSION_AUTH instead) | **Recommended: Use `~/.augment/session.json`** Create this file via `auggie login`, or manually with this format: ```json { "accessToken": "your-token-here", "tenantURL": "https://api.example.com/", "scopes": ["email"] } ``` **For CI/CD: Use `AUGMENT_SESSION_AUTH` environment variable** ```bash export AUGMENT_SESSION_AUTH='{"accessToken":"token","tenantURL":"https://api.example.com/"}' ``` **Migration Guide: Legacy to New Format** If currently using `ACE_API_URL` and `ACE_API_TOKEN`: 1. **Option A** (Recommended): Use `auggie login` to create `~/.augment/session.json` 2. **Option B**: Convert to `AUGMENT_SESSION_AUTH`: ```bash export AUGMENT_SESSION_AUTH='{"accessToken":"YOUR_ACE_API_TOKEN","tenantURL":"YOUR_ACE_API_URL"}' ``` 3. Remove old variables (optional, they'll be ignored if new format exists) Legacy variables continue to work for backward compatibility but are not recommended for new setups. ### Endpoint Configuration | Variable | Description | |----------|-------------| | `PROMPT_ENHANCER_ENDPOINT` | Endpoint override: `new`, `old`, `claude`, `openai`, `gemini`, `codex` | | `ACE_ENHANCER_ENDPOINT` | Legacy endpoint override (fallback if `PROMPT_ENHANCER_ENDPOINT` not set) | | `PROMPT_ENHANCER_BASE_URL` | Third-party API base URL (for claude/openai/gemini/codex endpoints) | | `PROMPT_ENHANCER_TOKEN` | Third-party API token | | `PROMPT_ENHANCER_MODEL` | Override default model for third-party endpoints | | `PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT` | Enable search context injection for third-party endpoints (`1`, `true`, `yes`, `on`) | ## Search Context Injection When `PROMPT_ENHANCER_INCLUDE_SEARCH_CONTEXT` is enabled and a third-party endpoint is used, the system automatically: 1. Performs a remote codebase search via ACE API using the original prompt as query 2. Normalizes the result (placeholder if empty, truncate at 12000 chars if too long) 3. Wraps the search results in `<codebase_context>` XML tags 4. Wraps the original prompt in `<original_request>` XML tags 5. Sends the combined prompt to the third-party LLM Requires `--project-root` and valid `ACE_API_URL`/`ACE_API_TOKEN`. Raises `ValueError` if `project_root` is missing when injection is enabled. ## .aceignore Place a `.aceignore` file in the project root to exclude additional patterns from code indexing (beyond `.gitignore`). Uses the same glob syntax as `.gitignore`. Patterns from both files are merged (union). Comments (`#`) and empty lines are skipped. ``` # .aceignore example test_fixtures/ *.generated.ts large_data/ node_modules/ .*/ logs/ tests/ ``` ## Workflow ### Phase 0: Index Project (once or after major changes) ```bash index -p . # Upload code blobs to ACE ``` ### Phase 1: Semantic Search ```bash search_context -p . -q "database connection pooling" # Remote retrieval or local fallback ``` ### Phase 2: Prompt Enhancement ```bash enhance_prompt -p "optimize query performance" --project-root . # With cloud context # Review and refine enhanced prompt # Use Regenerate/Refine as needed # Send Enhanced to confirm ``` ## Error Handling ```json {"error": "error message", "status_code": 401} ``` | Error | Recovery | |-------|----------| | No API configured | Uses local fallback for search_context; returns original for enhance | | Token invalid (401) | Check API token (logged at ERROR level) | | Access denied (403) | Token may be disabled (logged at ERROR level) | | Upload failure | Index rollback to previous state; cached blobs still returned | | Connection timeout | Retries up to 3 times with exponential backoff | | No results | Broaden search query | | Third-party not configured | `ValueError` raised immediately (no silent fallback) | | Search context missing project_root | `ValueError` raised when injection enabled without `--project-root` | ## Anti-Patterns | Prohibited | Correct | |------------|---------| | Grep before semantic search | Use `search_context` first | | Skip prompt enhancement | Use `enhance_prompt` for complex tasks | | Ignore conversation history | Include history in `enhance_prompt` | | Use exact match for conceptual search | Use natural language query | | Always use non-interactive mode | Use interactive mode for review | | Skip `--project-root` for enhance | Include it for cloud-based code context |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.